From 393e9b8aec52e81477ded5d6c22f03f96d788302 Mon Sep 17 00:00:00 2001 From: Senad Uka Date: Fri, 14 Jun 2019 17:41:09 +0200 Subject: [PATCH] Calculate door lock charges --- client/package.json | 1 + .../UploadDLockData/components/FileUpload.js | 96 +++- .../components/UnknownMapping.js | 172 ++++++ client/src/store/actions/doorLockActions.js | 7 +- client/src/store/actions/index.js | 1 + .../src/store/actions/integrationActions.js | 34 ++ client/src/store/constants.js | 8 + .../src/store/reducers/addMappingReducer.js | 38 ++ client/src/store/reducers/index.js | 6 +- client/src/store/reducers/mappingsReducer.js | 38 ++ client/yarn.lock | 4 + config/config.json | 5 +- constants/constants.js | 60 ++- controllers/doorLock.js | 48 +- controllers/integration.js | 37 ++ environment.env | 16 + ...1-change-resource-column-name-in-tables.js | 21 + ...d-columns-to-booking-reservations-table.js | 27 + ...-create-office-resource-name-mapping.js.js | 41 ++ ...rce-column-in-door-lock-events-table.js.js | 14 + ...ce-column-in-booking-reservations-table.js | 14 + ...123751-rename-door-lock-incidents-table.js | 11 + ...123950-alter-unscheduledIncidents-table.js | 35 ++ ...0612125150-add-unlocked-incidents-table.js | 35 ++ models/bookingReservation.js | 6 +- models/doorLockEvent.js | 5 +- models/officeResourceMapping.js | 14 + models/unlockedIncident.js | 29 + models/unscheduledIncident.js | 25 + package-lock.json | 41 +- package.json | 5 +- routes/index.js | 8 + server.js | 2 + services/doorLock/doorLock.js | 230 ++++++++ services/integration/doorLockCharges.js | 502 ++++++++++++++++++ services/officeRnD/bookings.js | 105 +++- services/officeRnD/resources.js | 61 +++ 37 files changed, 1736 insertions(+), 66 deletions(-) create mode 100644 client/src/scenes/UploadDLockData/components/UnknownMapping.js create mode 100644 client/src/store/actions/integrationActions.js create mode 100644 client/src/store/reducers/addMappingReducer.js create mode 100644 client/src/store/reducers/mappingsReducer.js create mode 100644 controllers/integration.js create mode 100644 migrations/20190603114921-change-resource-column-name-in-tables.js create mode 100644 migrations/20190603115116-add-columns-to-booking-reservations-table.js create mode 100644 migrations/20190608093226-create-office-resource-name-mapping.js.js create mode 100644 migrations/20190608093618-add-resource-column-in-door-lock-events-table.js.js create mode 100644 migrations/20190611072735-add-office-column-in-booking-reservations-table.js create mode 100644 migrations/20190612123751-rename-door-lock-incidents-table.js create mode 100644 migrations/20190612123950-alter-unscheduledIncidents-table.js create mode 100644 migrations/20190612125150-add-unlocked-incidents-table.js create mode 100644 models/officeResourceMapping.js create mode 100644 models/unlockedIncident.js create mode 100644 models/unscheduledIncident.js create mode 100644 services/doorLock/doorLock.js create mode 100644 services/integration/doorLockCharges.js create mode 100644 services/officeRnD/resources.js diff --git a/client/package.json b/client/package.json index c87178e..16941b2 100644 --- a/client/package.json +++ b/client/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "axios": "^0.18.0", + "fuse.js": "^3.4.5", "react": "^16.8.6", "react-dom": "^16.8.6", "react-redux": "^7.0.3", diff --git a/client/src/scenes/UploadDLockData/components/FileUpload.js b/client/src/scenes/UploadDLockData/components/FileUpload.js index b185805..87b1038 100644 --- a/client/src/scenes/UploadDLockData/components/FileUpload.js +++ b/client/src/scenes/UploadDLockData/components/FileUpload.js @@ -1,37 +1,96 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import {Form} from "semantic-ui-react"; +import { Form } from "semantic-ui-react"; -import { uploadDoorLockData } from "../../../store/actions"; +import UnknownMapping from './UnknownMapping'; + +import { uploadDoorLockData, fetchMappings } from "../../../store/actions"; class FileUpload extends Component { constructor(props) { super(props); this.state = { - file: null, + files: null, + unknownMappings: [], }; this.onFileChange = this.onFileChange.bind(this); this.onUploadClick = this.onUploadClick.bind(this); } + componentDidMount() { + const { fetchMappings } = this.props; + fetchMappings(); + } + + componentWillReceiveProps(nextProps, nextContext) { + const { addedMapping } = nextProps; + const { unknownMappings } = this.state; + + const filteredUnknownMappings = unknownMappings.filter(mapping => { + return mapping.officeSlug !== addedMapping.officeSlug + || mapping.resourceSlug !== addedMapping.resourceSlug + }); + + this.setState({unknownMappings: filteredUnknownMappings}); + } + + extractMappingFromFileName(fileName) { + const contentBetweenBracketsRegex = /\[(.*?)\]/; + const rawContent = fileName.match(contentBetweenBracketsRegex)[1]; + const mappingContent = rawContent.split('-').map(word => word.trim()); + return { + officeSlug: mappingContent[0], + resourceSlug: mappingContent[1], + } + } + + checkIfMappingExsists(mappingFromFileName) { + const { mappings } = this.props; + const { officeSlug, resourceSlug } = mappingFromFileName; + + const { existingMappings } = mappings; + + return existingMappings.find(mapping => (mapping.officeSlug === officeSlug) && (mapping.resourceSlug === resourceSlug)); + } + onFileChange(event) { - const file = event.target.files[0]; - this.setState({file}); + const files = event.target.files; + const unknownMappings = []; + + + Array.from(files).forEach(file => { + const mappingFromFileName = this.extractMappingFromFileName(file.name); + if (!this.checkIfMappingExsists(mappingFromFileName)){ + unknownMappings.push({ + file: file.name, + officeId: null, + resourceId: null, + officeSlug: mappingFromFileName.officeSlug, + resourceSlug: mappingFromFileName.resourceSlug, + }) + } + }); + + this.setState({files, unknownMappings}); }; onUploadClick() { const { uploadDoorLockData } = this.props; - const { file } = this.state; + const { files } = this.state; - if (file) { - uploadDoorLockData(file); + if (files) { + uploadDoorLockData(files); } }; render() { - const { pending } = this.props; + const { pendingUpload } = this.props; + const { unknownMappings, files } = this.state; + + const uploadDisabled = pendingUpload || unknownMappings.length > 0 || !files; + return (
- Upload + { + unknownMappings.map((mapping, index) => + ) + } +
+ Upload
); } } const mapStateToProps = (state) => ({ - pending: state.doorLockData.pending, + pendingUpload: state.doorLockData.pending, + pendingMappings: state.mappingsData.pending, + mappings: state.mappingsData.result, + addedMapping: state.addMapping.result, }); const mapDispatchToProps = (dispatch) => ({ - uploadDoorLockData: (doorLockDataFile) => uploadDoorLockData(dispatch, doorLockDataFile) + uploadDoorLockData: (doorLockDataFiles) => uploadDoorLockData(dispatch, doorLockDataFiles), + fetchMappings: () => fetchMappings(dispatch), }); export default connect(mapStateToProps, mapDispatchToProps)(FileUpload); diff --git a/client/src/scenes/UploadDLockData/components/UnknownMapping.js b/client/src/scenes/UploadDLockData/components/UnknownMapping.js new file mode 100644 index 0000000..3bee93e --- /dev/null +++ b/client/src/scenes/UploadDLockData/components/UnknownMapping.js @@ -0,0 +1,172 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import {Button, Dropdown, Message} from "semantic-ui-react"; +import Fuse from 'fuse.js'; +import { addNewMapping } from "../../../store/actions"; + +class UnknownMapping extends Component { + constructor(props) { + super(props); + + const guessedValues = this.guessDropdownValues(this.props); + + this.state = { + selectedOfficeId: guessedValues.officeValue, + selectedResourceId: guessedValues.resourceValue, + } + } + + componentWillReceiveProps(nextProps, nextContext) { + const guessedValues = this.guessDropdownValues(nextProps); + this.setState({selectedOfficeId: guessedValues.officeValue, selectedResourceId: guessedValues.resourceValue}); + } + + guessDropdownValues(props){ + const { mappings, mapping } = props; + + const offices = mappings && mappings.offices ? mappings.offices : []; + const resources = mappings && mappings.resources ? mappings.resources : []; + + const fuzzySearchOptions = { + shouldSort: true, + threshold: 0.5, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + "officeName", + "resourceName", + ] + }; + + const officesFuse = new Fuse(offices, fuzzySearchOptions); + const fuzzyOfficeSearchResults = officesFuse.search(mapping.officeSlug); + + let officeValue = null; + if (fuzzyOfficeSearchResults.length > 0){ + officeValue = fuzzyOfficeSearchResults[0].officeId; + }else if (offices.length > 0){ + officeValue = offices[0].officeId; + } + + const filteredResources = resources.filter(resource => resource.officeId === officeValue); + + const resourcesFuse = new Fuse(filteredResources, fuzzySearchOptions); + const fuzzyResourcesSearchResults = resourcesFuse.search(mapping.resourceSlug); + + let resourceValue = null; + if (fuzzyResourcesSearchResults.length > 0){ + resourceValue = fuzzyResourcesSearchResults[0].resourceId; + }else if (filteredResources.length > 0){ + resourceValue = filteredResources[0].resourceId; + } + + return { + officeValue, + resourceValue, + } + } + + onOfficeChange(event, data) { + const { mappings } = this.props; + + const selectedOfficeId = data.value || null; + const resources = mappings && mappings.resources ? mappings.resources : []; + const filteredResources = resources.filter(resource => resource.officeId === selectedOfficeId); + const selectedResourceId = filteredResources.length > 0 ? filteredResources[0].resourceId : null; + this.setState({selectedOfficeId, selectedResourceId}); + } + + onResourceChange(event, data) { + const selectedResourceId = data.value || null; + this.setState({selectedResourceId}); + } + + onSave(){ + const { addNewMapping, mapping } = this.props; + const { selectedOfficeId, selectedResourceId } = this.state; + const officeSlug = mapping.officeSlug; + const resourceSlug = mapping.resourceSlug; + + const newMapping = { + officeSlug, + resourceSlug, + officeId: selectedOfficeId, + resourceId: selectedResourceId, + }; + + addNewMapping(newMapping); + } + + render() { + const { mapping, mappings } = this.props; + const { selectedOfficeId, selectedResourceId } = this.state; + + const offices = mappings && mappings.offices ? mappings.offices : []; + const resources = mappings && mappings.resources ? mappings.resources : []; + + const officeDropdownOptions = offices.map(office => { + return { + key: office.officeId, + value: office.officeId, + text: office.officeName, + } + }); + + const filteredResources = resources.filter(resource => resource.officeId === selectedOfficeId); + + const resourceDropdownOptions = filteredResources.map(resource => { + return { + key: resource.resourceId, + value: resource.resourceId, + text: resource.resourceName, + } + }); + + const saveButtonDisabled = !selectedOfficeId || !selectedResourceId; + + return ( +
+
+ + {mapping.file} +
+ + This file contains the unknown location. Based on ORD data, it seems that this file is related to {' '} + + {' '} + / + {' '} + + +
+ +
+
); + } +} + +const mapStateToProps = (state) => ({ + mappings: state.mappingsData.result, +}); + +const mapDispatchToProps = (dispatch) => ({ + addNewMapping: (mapping) => addNewMapping(dispatch, mapping), +}); + + +export default connect(mapStateToProps, mapDispatchToProps)(UnknownMapping); diff --git a/client/src/store/actions/doorLockActions.js b/client/src/store/actions/doorLockActions.js index 00273f4..a5c5a01 100644 --- a/client/src/store/actions/doorLockActions.js +++ b/client/src/store/actions/doorLockActions.js @@ -6,9 +6,12 @@ import { import API from '../../utilities/api'; -export const uploadDoorLockData = (dispatch, doorLockDataFile) => { +export const uploadDoorLockData = (dispatch, doorLockDataFiles) => { const formData = new FormData(); - formData.append('doorLockDataFile', doorLockDataFile); + const filesArray = Array.from(doorLockDataFiles) || []; + filesArray.forEach((file, index) => { + formData.append(`doorLockDataFile-${index}`, file); + }); const additionalConfig = { headers: {'content-type': 'multipart/form-data'} }; diff --git a/client/src/store/actions/index.js b/client/src/store/actions/index.js index d21e20e..bc138de 100644 --- a/client/src/store/actions/index.js +++ b/client/src/store/actions/index.js @@ -1 +1,2 @@ export * from './doorLockActions'; +export * from './integrationActions'; diff --git a/client/src/store/actions/integrationActions.js b/client/src/store/actions/integrationActions.js new file mode 100644 index 0000000..b61b58b --- /dev/null +++ b/client/src/store/actions/integrationActions.js @@ -0,0 +1,34 @@ +import { + FETCH_MAPPINGS_PENDING, + FETCH_MAPPINGS_SUCCESS, + FETCH_MAPPINGS_FAILED, + ADD_NEW_MAPPING_PENDING, + ADD_NEW_MAPPING_SUCCESS, + ADD_NEW_MAPPING_FAILED, +} from "../constants"; + +import API from '../../utilities/api'; + +export const fetchMappings = (dispatch) => { + dispatch({type: FETCH_MAPPINGS_PENDING}); + API.get('integration/mappings') + .then(response => { + dispatch({type: FETCH_MAPPINGS_SUCCESS, payload: response.data}); + }) + .catch(error => { + dispatch({type: FETCH_MAPPINGS_FAILED, payload: error.response}); + }); +}; + +export const addNewMapping = (dispatch, mapping) => { + dispatch({type: ADD_NEW_MAPPING_PENDING}); + API.post('integration/mappings', { + mapping + }) + .then(response => { + dispatch({type: ADD_NEW_MAPPING_SUCCESS, payload: response.data}); + }) + .catch(error => { + dispatch({type: ADD_NEW_MAPPING_FAILED, payload: error.response}); + }); +}; diff --git a/client/src/store/constants.js b/client/src/store/constants.js index 5034375..aecc675 100644 --- a/client/src/store/constants.js +++ b/client/src/store/constants.js @@ -1,3 +1,11 @@ export const UPLOAD_DOOR_LOCK_DATA_PENDING = 'UPLOAD_DOOR_LOCK_DATA_PENDING'; export const UPLOAD_DOOR_LOCK_DATA_SUCCESS = 'UPLOAD_DOOR_LOCK_DATA_SUCCESS'; export const UPLOAD_DOOR_LOCK_DATA_FAILED = 'UPLOAD_DOOR_LOCK_DATA_FAILED'; + +export const FETCH_MAPPINGS_PENDING = 'FETCH_MAPPINGS_PENDING'; +export const FETCH_MAPPINGS_SUCCESS = 'FETCH_MAPPINGS_SUCCESS'; +export const FETCH_MAPPINGS_FAILED = 'FETCH_MAPPINGS_FAILED'; + +export const ADD_NEW_MAPPING_PENDING = 'ADD_NEW_MAPPING_PENDING'; +export const ADD_NEW_MAPPING_SUCCESS = 'ADD_NEW_MAPPING_SUCCESS'; +export const ADD_NEW_MAPPING_FAILED = 'ADD_NEW_MAPPING_FAILED'; diff --git a/client/src/store/reducers/addMappingReducer.js b/client/src/store/reducers/addMappingReducer.js new file mode 100644 index 0000000..c360882 --- /dev/null +++ b/client/src/store/reducers/addMappingReducer.js @@ -0,0 +1,38 @@ +import { + ADD_NEW_MAPPING_PENDING, + ADD_NEW_MAPPING_SUCCESS, + ADD_NEW_MAPPING_FAILED, +} from "../constants"; + +const initialState = { + pending: false, + result: null, + error: null, +}; + +export const addMapping = (state, action) => { + state = state || initialState; + action = action || {}; + + switch(action.type){ + case ADD_NEW_MAPPING_PENDING: + return Object.assign({}, state, { + pending: true, + error: null, + }); + case ADD_NEW_MAPPING_SUCCESS: + return Object.assign({}, state, { + pending: false, + result: action.payload, + error: null, + }); + case ADD_NEW_MAPPING_FAILED: + return Object.assign({}, state, { + pending: false, + result: {}, + error: action.payload, + }); + default: + return state; + } +}; diff --git a/client/src/store/reducers/index.js b/client/src/store/reducers/index.js index 274b7bd..ed2a547 100644 --- a/client/src/store/reducers/index.js +++ b/client/src/store/reducers/index.js @@ -1,8 +1,12 @@ import { combineReducers } from "redux"; import { doorLockData} from "./doorLockReducers"; +import { mappingsData } from "./mappingsReducer"; +import { addMapping } from './addMappingReducer'; export const rootReducer = combineReducers({ - doorLockData + doorLockData, + mappingsData, + addMapping, }); diff --git a/client/src/store/reducers/mappingsReducer.js b/client/src/store/reducers/mappingsReducer.js new file mode 100644 index 0000000..95e0d9d --- /dev/null +++ b/client/src/store/reducers/mappingsReducer.js @@ -0,0 +1,38 @@ +import { + FETCH_MAPPINGS_PENDING, + FETCH_MAPPINGS_SUCCESS, + FETCH_MAPPINGS_FAILED, +} from "../constants"; + +const initialState = { + pending: false, + result: null, + error: null, +}; + +export const mappingsData = (state, action) => { + state = state || initialState; + action = action || {}; + + switch(action.type){ + case FETCH_MAPPINGS_PENDING: + return Object.assign({}, state, { + pending: true, + error: null, + }); + case FETCH_MAPPINGS_SUCCESS: + return Object.assign({}, state, { + pending: false, + result: action.payload, + error: null, + }); + case FETCH_MAPPINGS_FAILED: + return Object.assign({}, state, { + pending: false, + result: {}, + error: action.payload, + }); + default: + return state; + } +}; diff --git a/client/yarn.lock b/client/yarn.lock index 8413b54..a426655 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3680,6 +3680,10 @@ functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" +fuse.js@^3.4.5: + version "3.4.5" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.5.tgz#8954fb43f9729bd5dbcb8c08f251db552595a7a6" + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" 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 db8cdc9..1650930 100644 --- a/constants/constants.js +++ b/constants/constants.js @@ -2,20 +2,66 @@ const USER_ENTRY_EVENT = 'User Entry'; const ENABLE_PASSAGE_MODE = 'Enable Passage Mode by Group 2'; const DISABLE_PASSAGE_MODE = 'Disable Passage Mode by Group 2'; -const USER_LOCKED_DOOR = 'locked'; -const USER_UNLOCKED_DOOR = 'unlocked'; - const VALID_CSV_HEADERS = ['Date', 'Time', 'User No', 'Name', 'Event']; + +const doorLockEvents = { + USER_LOCKED: 'locked', + USER_UNLOCKED: 'unlocked', +}; +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 = { @@ -23,8 +69,10 @@ module.exports = { USER_ENTRY_EVENT, ENABLE_PASSAGE_MODE, DISABLE_PASSAGE_MODE, - USER_LOCKED_DOOR, - USER_UNLOCKED_DOOR, csvParserErrors, officeRnDAPIErrors, + doorLockEvents, + unlockedIncidentLevelsPrices, + integrationServiceErrors, + incidentType, }; diff --git a/controllers/doorLock.js b/controllers/doorLock.js index 3e57137..68db008 100644 --- a/controllers/doorLock.js +++ b/controllers/doorLock.js @@ -1,23 +1,24 @@ 'use strict'; -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; const uploadDoorLockData = (req, res) => { const form = new IncomingForm(); - const parsingResults = []; + const fileParsers = []; form.on('file', (field, file) => { if (file && file.type === 'text/csv') { - parsingResults.push(parseDoorLockDataFile(file)); + fileParsers.push(parseDoorLockDataFile(file)); } }); form.on('end', () => { - Promise.all(parsingResults) + Promise.all(fileParsers) .then((parserResults) => { const parsedData = []; const parserErrors = []; @@ -29,30 +30,39 @@ 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(() => { - res.status(500).send(officeRnDAPIErrors.FAILED_TO_FETCH_MEMBERS); + .catch((error) => { + res.status(500).send(error); }); }); - form.parse(req); }; 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/20190603114921-change-resource-column-name-in-tables.js b/migrations/20190603114921-change-resource-column-name-in-tables.js new file mode 100644 index 0000000..a96138b --- /dev/null +++ b/migrations/20190603114921-change-resource-column-name-in-tables.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.renameColumn('bookingReservations', 'resource', 'resourceId'), + queryInterface.renameColumn('doorLockIncidents', 'resource', 'resourceId'), + ]); + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.renameColumn('doorLockIncidents', 'resourceId', 'resource'), + queryInterface.renameColumn('bookingReservations', 'resourceId', 'resource'), + ]); + }); + } +}; diff --git a/migrations/20190603115116-add-columns-to-booking-reservations-table.js b/migrations/20190603115116-add-columns-to-booking-reservations-table.js new file mode 100644 index 0000000..04c777c --- /dev/null +++ b/migrations/20190603115116-add-columns-to-booking-reservations-table.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.addColumn('bookingReservations', 'timezone', { + type: Sequelize.TEXT, + after: 'end' + }), + queryInterface.addColumn('bookingReservations', 'canceled', { + type: Sequelize.BOOLEAN, + after: 'timezone' + }) + ]); + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction((t) => { + return Promise.all([ + queryInterface.removeColumn('bookingReservations', 'canceled'), + queryInterface.removeColumn('bookingReservations', 'timezone') + ]); + }); + } +}; diff --git a/migrations/20190608093226-create-office-resource-name-mapping.js.js b/migrations/20190608093226-create-office-resource-name-mapping.js.js new file mode 100644 index 0000000..c158392 --- /dev/null +++ b/migrations/20190608093226-create-office-resource-name-mapping.js.js @@ -0,0 +1,41 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('officeResourceMappings', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + officeSlug: { + allowNull: false, + type: Sequelize.TEXT, + }, + officeId: { + allowNull: false, + type: Sequelize.TEXT, + }, + resourceSlug: { + allowNull: false, + type: Sequelize.TEXT, + }, + resourceId: { + allowNull: false, + type: Sequelize.TEXT, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('officeResourceMappings'); + } +}; diff --git a/migrations/20190608093618-add-resource-column-in-door-lock-events-table.js.js b/migrations/20190608093618-add-resource-column-in-door-lock-events-table.js.js new file mode 100644 index 0000000..c716163 --- /dev/null +++ b/migrations/20190608093618-add-resource-column-in-door-lock-events-table.js.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('doorLockEvents', 'resourceId', { + type: Sequelize.TEXT, + after: 'memberId', + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('doorLockEvents', 'resourceId'); + } +}; 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 3a10d3e..d977de3 100644 --- a/models/bookingReservation.js +++ b/models/bookingReservation.js @@ -4,9 +4,13 @@ module.exports = (sequelize, DataTypes) => { const bookingReservation = sequelize.define('bookingReservation', { reservationId: DataTypes.TEXT, memberId: DataTypes.TEXT, - resource: DataTypes.TEXT, + officeId: DataTypes.TEXT, + resourceId: DataTypes.TEXT, start: DataTypes.DATE, end: DataTypes.DATE, + timezone: DataTypes.TEXT, + canceled: DataTypes.BOOLEAN, + }, {}); bookingReservation.associate = function(models) { // associations can be defined here diff --git a/models/doorLockEvent.js b/models/doorLockEvent.js index e0a06e1..5a4a8d8 100644 --- a/models/doorLockEvent.js +++ b/models/doorLockEvent.js @@ -1,15 +1,16 @@ 'use strict'; -const { USER_LOCKED_DOOR, USER_UNLOCKED_DOOR } = require('../constants/constants'); +const { doorLockEvents } = require('../constants/constants'); module.exports = (sequelize, DataTypes) => { const doorLockEvent = sequelize.define('doorLockEvent', { memberName: DataTypes.TEXT, memberNumber: DataTypes.INTEGER, memberId: DataTypes.TEXT, + resourceId: DataTypes.TEXT, event: { type: DataTypes.ENUM, - values: [USER_LOCKED_DOOR, USER_UNLOCKED_DOOR] + values: [doorLockEvents.USER_LOCKED, doorLockEvents.USER_UNLOCKED] }, timestamp: DataTypes.DATE, }, {}); diff --git a/models/officeResourceMapping.js b/models/officeResourceMapping.js new file mode 100644 index 0000000..7005307 --- /dev/null +++ b/models/officeResourceMapping.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const officeResourceMapping = sequelize.define('officeResourceMapping', { + officeSlug: DataTypes.TEXT, + officeId: DataTypes.TEXT, + resourceSlug: DataTypes.TEXT, + resourceId: DataTypes.TEXT, + }, {}); + officeResourceMapping.associate = function(models) { + // associations can be defined here + }; + return officeResourceMapping; +}; 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/unscheduledIncident.js b/models/unscheduledIncident.js new file mode 100644 index 0000000..1f781b9 --- /dev/null +++ b/models/unscheduledIncident.js @@ -0,0 +1,25 @@ +'use strict'; + +const { doorLockEvents } = require('../constants/constants'); + +module.exports = (sequelize, DataTypes) => { + const unscheduledIncident = sequelize.define('unscheduledIncident', { + reservationId: DataTypes.TEXT, + memberId: DataTypes.TEXT, + resourceId: DataTypes.TEXT, + bookingStart: DataTypes.DATE, + bookingEnd: DataTypes.DATE, + doorLockEventTimestamp: DataTypes.DATE, + doorLockEventType: { + type: DataTypes.ENUM, + values: [doorLockEvents.USER_LOCKED, doorLockEvents.USER_UNLOCKED] + }, + chargePrice: DataTypes.FLOAT, + timeIntervalsToCharge: DataTypes.INTEGER, + totalChargeFee: DataTypes.FLOAT, + }, {}); + unscheduledIncident.associate = function(models) { + // associations can be defined here + }; + return unscheduledIncident; +}; diff --git a/package-lock.json b/package-lock.json index 85b4ea6..ec355d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,12 +120,19 @@ "dev": true }, "axios": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz", - "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", + "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", "requires": { - "follow-redirects": "^1.3.0", - "is-buffer": "^1.1.5" + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" + } } }, "babel-runtime": { @@ -1128,20 +1135,25 @@ } }, "follow-redirects": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.7.0.tgz", - "integrity": "sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", "requires": { - "debug": "^3.2.6" + "debug": "=3.1.0" }, "dependencies": { "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", "requires": { - "ms": "^2.1.1" + "ms": "2.0.0" } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" } } }, @@ -1982,7 +1994,8 @@ "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "is-ci": { "version": "1.2.1", diff --git a/package.json b/package.json index b8e611e..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", @@ -21,13 +21,14 @@ "node": "11.12.x" }, "dependencies": { - "axios": "^0.18.0", + "axios": "^0.19.0", "csv-parser": "^2.3.0", "dotenv": "^8.0.0", "express": "^4.17.0", "express-basic-auth": "^1.2.0", "formidable": "^1.2.1", "moment": "^2.24.0", + "moment-timezone": "^0.5.25", "pg": "^7.11.0", "sequelize": "^5.8.6", "sequelize-cli": "^5.4.0" diff --git a/routes/index.js b/routes/index.js index f5b4e2c..6e007a2 100644 --- a/routes/index.js +++ b/routes/index.js @@ -2,11 +2,19 @@ const { apiStatusCheck } = require('../controllers/apiStatusCheck'); 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(); router.get('/', apiStatusCheck); + router.post('/doorLock/upload', uploadDoorLockData); +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/server.js b/server.js index bbb332e..ff8a27d 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,8 @@ const app = express(); const port = process.env.PORT || 5000; +app.use(express.json()); + app.use('/api', routes); app.use(basicAuth({ diff --git a/services/doorLock/doorLock.js b/services/doorLock/doorLock.js new file mode 100644 index 0000000..233df5d --- /dev/null +++ b/services/doorLock/doorLock.js @@ -0,0 +1,230 @@ +'use strict'; + +const db = require('../../models'); +const fs = require('fs'); +const csv = require('csv-parser'); +const moment = require('moment/moment'); +const Op = require('sequelize').Op; + +const { + USER_ENTRY_EVENT, + ENABLE_PASSAGE_MODE, + DISABLE_PASSAGE_MODE, + VALID_CSV_HEADERS, + doorLockEvents, + csvParserErrors, +} = require('../../constants/constants'); + +const { fetchAllMembers, findMember } = require('../officeRnD/members'); +const { getMappingsFromDatabase } = require('../officeRnD/resources'); + +const extractMappingFromFileName = (fileName) => { + const contentBetweenBracketsRegex = /\[(.*?)\]/; + const rawContent = fileName.match(contentBetweenBracketsRegex)[1]; + const mappingContent = rawContent.split('-').map(word => word.trim()); + return { + officeSlug: mappingContent[0], + resourceSlug: mappingContent[1], + } +}; + +const checkIfMappingExsists = (mappingFromFileName, mappings) => { + const { officeSlug, resourceSlug } = mappingFromFileName; + + return mappings.find(mapping => (mapping.officeSlug === officeSlug) && (mapping.resourceSlug === resourceSlug)); +}; + +const parseDoorLockDataFile = (file) => { + return new Promise ((resolve, reject) => { + const results = []; + const errors = []; + const unknownMembers = []; + let isValidFile = true; + + const prefetchDataJobs = [getMappingsFromDatabase(), fetchAllMembers()]; + + Promise.all(prefetchDataJobs) + .then(result => { + const mappings = result[0]; + + const mappingFromFileName = extractMappingFromFileName(file.name); + const mappingObject = checkIfMappingExsists(mappingFromFileName, mappings); + if (!mappingObject){ + reject('Error ! File contains unknown location'); + return; + } + + fs.createReadStream(file.path) + .pipe(csv({ + mapHeaders: ({ header, index }) => header.trim().toLowerCase(), + mapValues: ({ header, index, value }) => value.trim() + })) + .on('headers', (headers) => { + + const sortedValidHeadersArray = VALID_CSV_HEADERS.concat().sort(); + const sortedParsedHeadersArray = headers.map(header => header.trim()).sort(); + + let validHeaders = true; + if (sortedParsedHeadersArray.length !== sortedValidHeadersArray.length) { + validHeaders = false; + }else { + for (let i = 0; i < sortedValidHeadersArray.length; i++){ + validHeaders = validHeaders + && (sortedValidHeadersArray[i] === sortedParsedHeadersArray[i]); + } + } + + if (!validHeaders){ + isValidFile = false; + errors.push({ + error: csvParserErrors.INVALID_HEADERS, + details: `Expected headers : ${JSON.stringify(VALID_CSV_HEADERS)}`, + file: file.name, + }); + } + }) + .on('data', (data) => { + if (!isValidFile) { + return; + } + + const eventType = data.event.trim(); + if ([USER_ENTRY_EVENT, ENABLE_PASSAGE_MODE, DISABLE_PASSAGE_MODE].includes(eventType)){ + results.push(data); + } + }) + .on('end', () => { + const parsedData = []; + let i = 0; + while (i < results.length){ + //Verify pair + //First entry type should be user entry and second should be enable / disable passage + const firstEntry = results[i]; + const secondEntry = results[i+1]; + + if (firstEntry && (firstEntry.event === USER_ENTRY_EVENT)){ + const memberObject = findMember(firstEntry.name); + + if (!memberObject){ + //Check if member is already labeled as unknown + const unknownMember = unknownMembers.find((member) => member.details === firstEntry.name); + if (!unknownMember){ + unknownMembers.push({ + error: csvParserErrors.UNKNOWN_MEMBER, + details: firstEntry.name, + file: file.name, + }); + } + } + + if (secondEntry && (secondEntry.event === ENABLE_PASSAGE_MODE || secondEntry.event === DISABLE_PASSAGE_MODE)){ + const event = (secondEntry.event === ENABLE_PASSAGE_MODE) ? + doorLockEvents.USER_UNLOCKED : doorLockEvents.USER_LOCKED; + + const dateTimeString = `${firstEntry.date} ${firstEntry.time}`; + const timestamp = moment.utc(dateTimeString, 'MM/DD/YY HH:mm:ss A').toISOString(); + + //Verify that member is registered in OfficeRnD system + if (memberObject){ + const entryData = { + memberName: firstEntry.name, + memberNumber: firstEntry['user no'], + memberId: memberObject.memberId, + timestamp, + event, + resourceId: mappingObject.resourceId, + + }; + + parsedData.push(entryData); + } + i+=2; + } else { + errors.push({ + error: csvParserErrors.INVALID_ENTRY_EXPECTED_PASSAGE_MODE, + details: firstEntry, + file: file.name, + }); + i+=1; + } + } else { + errors.push({ + error: csvParserErrors.INVALID_ENTRY_EXPECTED_USER, + details: firstEntry, + file: file.name, + }); + i+=1; + } + } + resolve({ + parsedData, + unknownMembers, + errors + }); + }); + + }) + .catch(error => { + reject(error); + }); + }); +}; + +const writeDoorLockEvent = (entry) => { + 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 ad834bd..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,26 +18,118 @@ const fetchAllBookings = () => { cleanedBookingReservations.push({ reservationId: fullBookingEntry['_id'], memberId: fullBookingEntry.member, - resource: fullBookingEntry.resourceId, + 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) => { - db.bookingReservation.findOrCreate({where: {...bookingReservation}, defaults: {...bookingReservation}}) - .then() - .catch(); + return db.bookingReservation.findOrCreate({where: {...bookingReservation}, defaults: {...bookingReservation}}); }; module.exports = { fetchAllBookings, writeBookingReservation, + getAllFinishedBookings, + getFirstNextBooking, + getFirstPreviousBooking, }; diff --git a/services/officeRnD/resources.js b/services/officeRnD/resources.js new file mode 100644 index 0000000..95b1d64 --- /dev/null +++ b/services/officeRnD/resources.js @@ -0,0 +1,61 @@ +'use strict'; + +const db = require('../../models/index'); + +const { API } = require('../../helpers/api'); + +const fetchOffices = () => { + return new Promise((resolve, reject) => { + API.get('/offices') + .then((result) => { + const offices = result.data || []; + const cleanedOffices = []; + offices.forEach(office => { + cleanedOffices.push({ + officeId: office['_id'], + officeName: office.name, + }); + }); + resolve(cleanedOffices); + }) + .catch((error) => { + reject(error); + }); + }); +}; + +const fetchResources = () => { + return new Promise((resolve, reject) => { + API.get('/resources') + .then((result) => { + const resources = result.data || []; + const cleanedResources = []; + resources.forEach(resource => { + cleanedResources.push({ + resourceId: resource['_id'], + resourceName: resource.name, + officeId: resource.office, + }); + }); + resolve(cleanedResources); + }) + .catch((error) => { + reject(error); + }); + }); +}; + +const getMappingsFromDatabase = () => { + return db.officeResourceMapping.findAll(); +}; + +const saveNewMappingToDatabase = (mapping) => { + return db.officeResourceMapping.findOrCreate({where: {...mapping}, defaults: {...mapping}}); +}; + +module.exports = { + getMappingsFromDatabase, + fetchOffices, + fetchResources, + saveNewMappingToDatabase, +};