Browse Source

Initial Commit

Henry Jameson 4 years ago
commit
94b504b854

+ 11
- 0
.eslintrc.js View File

@@ -0,0 +1,11 @@
1
+module.exports = {
2
+  "extends": "standard",
3
+  "overrides": [
4
+    {
5
+      files: "*.test.js",
6
+      rules: {
7
+        "no-unused-expressions": "off"
8
+      }
9
+    }
10
+]
11
+};

+ 73
- 0
.gitignore View File

@@ -0,0 +1,73 @@
1
+# Logs
2
+logs
3
+*.log
4
+npm-debug.log*
5
+yarn-debug.log*
6
+yarn-error.log*
7
+
8
+# Runtime data
9
+pids
10
+*.pid
11
+*.seed
12
+*.pid.lock
13
+
14
+# Directory for instrumented libs generated by jscoverage/JSCover
15
+lib-cov
16
+
17
+# Coverage directory used by tools like istanbul
18
+coverage
19
+
20
+# nyc test coverage
21
+.nyc_output
22
+
23
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24
+.grunt
25
+
26
+# Bower dependency directory (https://bower.io/)
27
+bower_components
28
+
29
+# node-waf configuration
30
+.lock-wscript
31
+
32
+# Compiled binary addons (https://nodejs.org/api/addons.html)
33
+build/Release
34
+
35
+# Dependency directories
36
+node_modules/
37
+jspm_packages/
38
+
39
+# TypeScript v1 declaration files
40
+typings/
41
+
42
+# Optional npm cache directory
43
+.npm
44
+
45
+# Optional eslint cache
46
+.eslintcache
47
+
48
+# Optional REPL history
49
+.node_repl_history
50
+
51
+# Output of 'npm pack'
52
+*.tgz
53
+
54
+# Yarn Integrity file
55
+.yarn-integrity
56
+
57
+# dotenv environment variables file
58
+.env
59
+
60
+# parcel-bundler cache (https://parceljs.org/)
61
+.cache
62
+
63
+# next.js build output
64
+.next
65
+
66
+# nuxt.js build output
67
+.nuxt
68
+
69
+# vuepress build output
70
+.vuepress/dist
71
+
72
+# Serverless directories
73
+.serverless

+ 25
- 0
index.js View File

@@ -0,0 +1,25 @@
1
+#!/usr/bin/env node
2
+
3
+const program = require('commander')
4
+const CustomerFinder = require('./lib/CustomerFinder.js')
5
+const CustomerListFromURL = require('./lib/CustomerListFromURL.js')
6
+
7
+program
8
+  .version('0.0.1')
9
+  .option('--customers [url]', 'URL for customers list [by default uses the URL provided in email ;)].', 'https://s3.amazonaws.com/intercom-take-home-test/customers.txt')
10
+  .option('--home [home]', '"Home" location - distances to customers will be calculated against it [Default is [-6.257664, 53.339428]].', [-6.257664, 53.339428])
11
+  .option('--no-orm', 'Disables fetching distance via OpenRouteMap.')
12
+  .option('--direct-routes', 'Fall back to simple calculation if ORM tells there are no routes.')
13
+  .option('-d, --distance [meters]', 'Maximum exclusive distance in meters [100000]', 100000)
14
+  .parse(process.argv)
15
+
16
+const customerListFromURL = new CustomerListFromURL(program.customers)
17
+const customerFinder = new CustomerFinder(customerListFromURL, program.home, { noORM: !program.orm, fallbackOnInfinity: program.directRoutes })
18
+
19
+console.log('Processing...')
20
+customerFinder.findNearestCustomers(program.distance).then(customers => {
21
+  console.log('Fitting candidates for invitation:')
22
+  customers.forEach(customer => {
23
+    console.log('- ' + customer.name)
24
+  })
25
+})

+ 58
- 0
lib/CustomerFinder.js View File

@@ -0,0 +1,58 @@
1
+const orm = new (require('./OpenRouteMapDistanceResolver.js'))()
2
+const sd = new (require('./SimpleDistanceResolver.js'))()
3
+
4
+class CustomerFinder {
5
+/**
6
+ * @param {Type of customerListProvider} customerListProvider - provider that would give list of customers
7
+ * @param {Type of homeLocation} homeLocation - the location where event would take place
8
+ * @param {Object} [options] - optional arguments
9
+ * @param {Boolean} [options.noORM] - disable fetching distance via ORM
10
+ * @param {Boolean} [options.fallbackOnInfinity] - disable falling back to simple calculation in case ORM couldn't find routes
11
+ */
12
+  constructor (customerListProvider, homeLocation, options) {
13
+    this._customers = customerListProvider.getList()
14
+    this._home = homeLocation
15
+    options = options || {}
16
+    this._noORM = options.noORM || false
17
+    this._fallbackOnInfinity = options.fallbackOnInfinity || true
18
+  }
19
+
20
+  /**
21
+   * Asynchronously fetches the distance between "home" and target customer location
22
+   * unless noORM is true, it tries to fetch route length from ORM and falls back to
23
+   * simple greater-circle length calculation
24
+   * @param {Customer} customer - customer from data
25
+   * @param {Boolean} noORM - disable fetching from ORM
26
+   * @returns {Promise<Number>} Promise of a distance in meters
27
+   */
28
+  _getCustomerDistance (customer, noORM) {
29
+    const dr = noORM ? sd : orm
30
+    return dr.getDistance(this._home, [customer.longitude, customer.latitude])
31
+      .then(distance => (distance === Number.POSITIVE_INFINITY && this._fallbackOnInfinity)
32
+        ? this._getCustomerDistance(customer, true)
33
+        : distance)
34
+    // probably should fall back for all following queries depending on response
35
+      .catch(e => console.error(`Failed fetching length via ${dr.constructor.name}: ${e.message}, falling back... to simple calculation`) ||
36
+             this._getCustomerDistance(customer, true))
37
+  }
38
+
39
+  /**
40
+   * Returns the list of customers within given limit
41
+   * @param {Number} distanceLimit - maximum (exclusive) distance between event location and customer location
42
+   * @returns {Promise<Customer[]>} filtered list of customers that match the distance criteria
43
+   */
44
+  findNearestCustomers (distanceLimit) {
45
+    return this._customers
46
+      .then(customers => customers
47
+        .map(async customer => ((await this._getCustomerDistance(customer, this._noORM) < distanceLimit)) ? customer : undefined)
48
+      )
49
+      .then(promises => Promise.all(promises))
50
+      .then(customers => customers.filter(_ => _))
51
+  }
52
+}
53
+
54
+// just a test
55
+// (new CustomerFinder(new (require('./CustomerListFromURL.js'))('https://s3.amazonaws.com/intercom-take-home-test/customers.txt'), [-6.257664, 53.339428], { noORM: false })).findNearestCustomers(100 * 1000).then(_ => console.log(_.length))
56
+// this is what would be happening.
57
+
58
+module.exports = CustomerFinder

+ 32
- 0
lib/CustomerListFromURL.js View File

@@ -0,0 +1,32 @@
1
+const rp = require('request-promise-native')
2
+
3
+class CustomerListFromURL {
4
+  constructor (url) {
5
+    this._url = url
6
+  }
7
+
8
+  /**
9
+   * internal function that actually fetches data
10
+   * @returns {Promise<Response>} request-promise promise
11
+   */
12
+  _fetch () {
13
+    return rp(this._url)
14
+  }
15
+
16
+  /**
17
+   * fetches list and processes it into usable JS array
18
+   * @returns {Customer[]} list of customers
19
+   */
20
+  getList () {
21
+    return this._fetch()
22
+    // The data in supplied URL isn't exactly JSON array, instead a list of JSON objects separated by newlines.
23
+      .then((response) => response
24
+        .split('\n') // splitting by newlines
25
+        .map(_ => _.trim()) // trimming strings
26
+        .filter(_ => _) // filter out falsy items (undefineds, empty strings)
27
+        .map(_ => JSON.parse(_))
28
+      )
29
+  }
30
+}
31
+
32
+module.exports = CustomerListFromURL

+ 27
- 0
lib/CustomerListFromURL.test.js View File

@@ -0,0 +1,27 @@
1
+require('chai').should()
2
+describe('CustomerListFromURL', function () {
3
+  let customerListFromURL
4
+
5
+  beforeEach(function () {
6
+    customerListFromURL = new (require('./CustomerListFromURL.js'))('')
7
+    // We mock the actual fetching of data.
8
+    customerListFromURL._fetch = () => Promise.resolve(`
9
+      {"latitude": "52.986375", "user_id": 12, "name": "Christina McArdle", "longitude": "-6.043701"}
10
+      {"latitude": "51.92893", "user_id": 1, "name": "Alice Cahill", "longitude": "-10.27699"}
11
+      {"latitude": "51.8856167", "user_id": 2, "name": "Ian McArdle", "longitude": "-10.4240951"}
12
+      {"latitude": "52.3191841", "user_id": 3, "name": "Jack Enright", "longitude": "-8.5072391"}
13
+    `)
14
+  })
15
+
16
+  it('getList should correctly parse the input', async function () {
17
+    const result = await customerListFromURL.getList()
18
+    result.should.be.an('array')
19
+    result.should.be.have.lengthOf(4)
20
+    result.should.have.deep.members([
21
+      {latitude: '52.986375', user_id: 12, name: 'Christina McArdle', longitude: '-6.043701'},
22
+      {latitude: '51.92893', user_id: 1, name: 'Alice Cahill', longitude: '-10.27699'},
23
+      {latitude: '51.8856167', user_id: 2, name: 'Ian McArdle', longitude: '-10.4240951'},
24
+      {latitude: '52.3191841', user_id: 3, name: 'Jack Enright', longitude: '-8.5072391'}
25
+    ])
26
+  })
27
+})

+ 22
- 0
lib/DistanceResolver.js View File

@@ -0,0 +1,22 @@
1
+class DistanceResolver {
2
+  getDistance (A, B) {
3
+    A = this._sanitizeCoords(A)
4
+    B = this._sanitizeCoords(B)
5
+  }
6
+
7
+  _sanitizeCoords (coords) {
8
+    if (!Array.isArray(coords)) throw new Error('Coordinates are not an array!')
9
+    if (coords.length !== 2) throw new Error('Coordinates are not in form of [lon, lat] array!')
10
+    let lon = coords[0]
11
+    let lat = coords[1]
12
+    if (typeof lon !== 'number') lon = Number(lon)
13
+    if (typeof lat !== 'number') lat = Number(lat)
14
+    if (Number.isNaN(lon)) throw new Error('Invalid longitude!')
15
+    if (Number.isNaN(lat)) throw new Error('Invalid latitude!')
16
+    if (Math.abs(lat) > 90) throw new Error('Latitude must be within [-90;90] degrees')
17
+    if (Math.abs(lon) > 180) throw new Error('Longitude must be within [-180;180] degrees')
18
+    return [lon, lat]
19
+  }
20
+}
21
+
22
+module.exports = DistanceResolver

+ 26
- 0
lib/DistanceResolver.test.js View File

@@ -0,0 +1,26 @@
1
+require('chai').should()
2
+describe('DistanceResolver', function () {
3
+  const dr = new (require('./DistanceResolver.js'))()
4
+  describe('getDistance should throw exception when', function () {
5
+    it('longitude is out of bounds', function () {
6
+      (() => dr.getDistance([0, 0], [181, 0])).should.throw()
7
+    })
8
+    it('latitude is out of bounds', function () {
9
+      (() => dr.getDistance([0, 0], [180, -91])).should.throw()
10
+    })
11
+    it('invalid strings', function () {
12
+      (() => dr.getDistance(['one hundred', 0], [0, 0])).should.throw()
13
+    })
14
+    it('invalid arrays', function () {
15
+      (() => dr.getDistance([0, 0, 0], [0, 0])).should.throw()
16
+    })
17
+    it('invalid input', function () {
18
+      (() => dr.getDistance('An array', [0, 0])).should.throw()
19
+    })
20
+  })
21
+
22
+  it('_sanitizeCoords should convert string numbers to numbers', function () {
23
+    (() => dr._sanitizeCoords(['1', '-3'])).should.not.throw()
24
+    dr._sanitizeCoords(['1', '-3']).should.have.deep.members([1, -3])
25
+  })
26
+})

+ 33
- 0
lib/OpenRouteMapDistanceResolver.js View File

@@ -0,0 +1,33 @@
1
+const DistanceResovler = require('./DistanceResolver.js')
2
+
3
+/*
4
+  * This is a free key with 2500 requests per day restriction. Ideally this would
5
+  * have been stored in a separate location (not stored under VCS) like in CI
6
+  * deployment secrets storage. For simplicity's sake I left it here so that
7
+  * code will actually work.
8
+  */
9
+const apiKey = '58d904a497c67e00015b45fc711d2a70bece433faf282bd510515061'
10
+const rp = require('request-promise-native')
11
+
12
+class OpenRouteMapDistanceResolver extends DistanceResovler {
13
+/**
14
+ * Gets (route) distance between point A and point B
15
+ * @param {Number[]} A - array [longitude, latitude]
16
+ * @param {Number[]} B - array [longitude, latitude]
17
+ * @returns {Return Promise<Number>} distance between points in meters
18
+ */
19
+  getDistance (A, B) {
20
+    // profile profile drive-car gives "can't find point" for some locations, i suppose the ones that are not reachable via car
21
+    return rp(`https://api.openrouteservice.org/directions?api_key=${apiKey}&coordinates=${A.join(',')}%7C${B.join(',')}&profile=foot-walking`)
22
+      .then(JSON.parse)
23
+      // in case there are no routes between points assume there's really no route.
24
+      // i'm not sure if that's how it works tho
25
+      .then(response => response.routes.length === 0 ? Number.POSITIVE_INFINITY : response.routes[0].summary.distance)
26
+  }
27
+}
28
+
29
+// Just a test
30
+// (new OpenRouteMapDistanceResolver()).getDistance([8.685755, 49.393505], [8.686141, 49.389643]).then(console.log)
31
+// Should be around 430
32
+
33
+module.exports = OpenRouteMapDistanceResolver

+ 28
- 0
lib/SimpleDistanceResolver.js View File

@@ -0,0 +1,28 @@
1
+const DistanceResovler = require('./DistanceResolver.js')
2
+
3
+class SimpleDistanceResolver extends DistanceResovler {
4
+  /**
5
+   * Gets (great-circle) distance between point A and point B
6
+   * @param {Number[]} A - array [longitude, latitude]
7
+   * @param {Number[]} B - array [longitude, latitude]
8
+   * @returns {Return Promise<Number>} distance between points in meters
9
+   */
10
+  getDistance (A, B) {
11
+    super.getDistance(A, B)
12
+    const radConv = Math.PI / 180
13
+    const earthRadius = 6371 * 1000 // kilometers to meters
14
+
15
+    const lonA = A[0] * radConv
16
+    const lonB = B[0] * radConv
17
+    const latA = A[1] * radConv
18
+    const latB = B[1] * radConv
19
+
20
+    const lonD = Math.abs(lonA - lonB)
21
+
22
+    const absAngle = Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonD))
23
+
24
+    return Promise.resolve(absAngle * earthRadius)
25
+  }
26
+}
27
+
28
+module.exports = SimpleDistanceResolver

+ 28
- 0
lib/SimpleDistanceResolver.test.js View File

@@ -0,0 +1,28 @@
1
+require('chai').should()
2
+describe('SimpleDistanceResolver', function () {
3
+  const simpleDistanceResolver = new (require('./SimpleDistanceResolver.js'))()
4
+
5
+  describe('getDistance', async function () {
6
+    it('short distance', async function () {
7
+      // Some points on straight road and distance provided by OpenRouteService
8
+      // so output of our function should relatively match the expectations
9
+      const A = [8.685755, 49.393505]
10
+      const B = [8.686141, 49.389643]
11
+      const realDist = 432
12
+
13
+      const distance = await simpleDistanceResolver.getDistance(A, B)
14
+      distance.should.be.closeTo(realDist, 5)
15
+    })
16
+
17
+    it('long distance', async function () {
18
+      // Some points on some very long straight road in USA and distance provided by OpenRouteService
19
+      // so output of our function should relatively match the expectations
20
+      const A = [-99.358635, 35.84109]
21
+      const B = [-99.35885, 35.725646]
22
+      const realDist = 12834
23
+
24
+      const distance = await simpleDistanceResolver.getDistance(A, B)
25
+      distance.should.be.closeTo(realDist, 5)
26
+    })
27
+  })
28
+})

+ 2051
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 26
- 0
package.json View File

@@ -0,0 +1,26 @@
1
+{
2
+  "name": "partymaker",
3
+  "version": "0.0.1",
4
+  "description": "Test exercise for Intercom",
5
+  "main": "index.js",
6
+  "scripts": {
7
+    "test": "mocha --recursive lib/**/*.test.js"
8
+  },
9
+  "author": "Roman Kostetskiy",
10
+  "license": "GPL-3.0-or-later",
11
+  "dependencies": {
12
+    "commander": "^2.16.0",
13
+    "request": "^2.87.0",
14
+    "request-promise-native": "^1.0.5"
15
+  },
16
+  "devDependencies": {
17
+    "chai": "^4.1.2",
18
+    "eslint": "^5.1.0",
19
+    "eslint-config-standard": "^11.0.0",
20
+    "eslint-plugin-import": "^2.13.0",
21
+    "eslint-plugin-node": "^7.0.1",
22
+    "eslint-plugin-promise": "^3.8.0",
23
+    "eslint-plugin-standard": "^3.1.0",
24
+    "mocha": "^5.2.0"
25
+  }
26
+}