├── .gitignore ├── Capfile ├── LICENSE ├── README.md ├── amqp_consumer.js ├── app.js ├── backend.js ├── client-app.js ├── config ├── app.yml ├── deploy.rb ├── deploy │ ├── production.rb │ └── staging.rb └── runtime.json ├── disable.sh ├── dispatch.js ├── dispatch.sublime-project ├── driver-app.js ├── error_codes.js ├── latency.sh ├── latlon.js ├── lib ├── cache.js ├── google-distance.js ├── repository.js ├── reverse_geocoder.js └── schedule.js ├── log.js ├── messageFactory.js ├── models ├── city.js ├── client.js ├── driver.js ├── trip.js └── user.js ├── mongo_client.js ├── package.json └── publisher.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .DS_STORE 3 | android/* 4 | TODO 5 | uber_logs/* 6 | *.sublime-workspace 7 | v8.log -------------------------------------------------------------------------------- /Capfile: -------------------------------------------------------------------------------- 1 | # Load DSL and Setup Up Stages 2 | require 'capistrano/setup' 3 | 4 | # Includes default deployment tasks 5 | require 'capistrano/deploy' 6 | 7 | # Includes tasks from other gems included in your Gemfile 8 | # 9 | # For documentation on these, see for example: 10 | # 11 | # https://github.com/capistrano/rvm 12 | # https://github.com/capistrano/rbenv 13 | # https://github.com/capistrano/chruby 14 | # https://github.com/capistrano/bundler 15 | # https://github.com/capistrano/rails 16 | # 17 | # require 'capistrano/rvm' 18 | # require 'capistrano/rbenv' 19 | # require 'capistrano/chruby' 20 | # require 'capistrano/bundler' 21 | # require 'capistrano/rails/assets' 22 | # require 'capistrano/rails/migrations' 23 | 24 | # Loads custom tasks from `lib/capistrano/tasks' if you have any defined. 25 | Dir.glob('lib/capistrano/tasks/*.cap').each { |r| import r } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Paul Tisunov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Welcome to Instacab Dispatcher 2 | 3 | Instacab Dispatcher is a Node.js app which coordinates drivers and clients, dispatches ride requests to nearest drivers, keeps GPS logs, provides API via WebSockets for client and driver apps, provides ETA using [Google Distance Matrix](https://developers.google.com/maps/documentation/distancematrix/), provides REST API for `God View` interface to display all clients, drivers and trips in real-time on the map. 4 | 5 | ## How Does It Work 6 | 7 | * Listens port 9000 for API WebSocket connections 8 | * Waits for AMQP (RabbitMQ) messages from [Instacab Backend](https://github.com/tisunov/Instacab/) to update city vehicle options availability 9 | * All client and driver state is kept in memory with Redis as storage between restarts. 10 | 11 | ## Requirements 12 | * Node.js 0.10.x 13 | * Redis 2.8 14 | * MongoDB 15 | * RabbitMQ 16 | 17 | ## Client API Interface 18 | 19 | * TODO 20 | 21 | ## Driver API Interface 22 | 23 | * TODO 24 | 25 | ## Getting Started 26 | 27 | 1. Checkout Dispatcher source at the command prompt if you haven't yet: 28 | 29 | git checkout https://github.com/tisunov/InstacabDispatcher 30 | 31 | 2. At the command prompt, install required npm packages: 32 | 33 | npm install 34 | 35 | 3. Install and start [RabbitMQ](http://www.rabbitmq.com/download.html) 36 | 37 | 4. Start Instacab Dispatcher 38 | 39 | node app.js 40 | 41 | 5. Start Instacab Backend 42 | 43 | ## Setting Up Instacab Backend 44 | 45 | Please refer to [Instacab Backend](https://github.com/tisunov/Instacab/) 46 | 47 | ## Instacab iPhone Client App 48 | 49 | Please refer to [Instacab Client](https://github.com/tisunov/InstacabClient/) 50 | 51 | ## Known Bugs 52 | * Memory footprint keeps growing while running in production, node.js memory leak detection tools yielded no results so far. 53 | 54 | ## TODO 55 | 56 | - [ ] Write unit tests 57 | - [ ] Translate Russian strings 58 | - [ ] Consider ditching WebSockets in favor REST API, we are updating whole app state anyways. 59 | - [ ] Use AMQP to communicate with backend instead HTTP 60 | - [ ] Use [winston](https://github.com/flatiron/winston) for logging -------------------------------------------------------------------------------- /amqp_consumer.js: -------------------------------------------------------------------------------- 1 | var amqp = require('amqplib'), 2 | util = require('util'), 3 | clients = require('./models/client').repository, 4 | city = require('./models/city'); 5 | 6 | var CITY_UPDATES_QUEUE = 'city_updated'; 7 | var CLIENT_UPDATES_QUEUE = 'client_updated'; 8 | 9 | 10 | // Messaging in Node.JS with RabbitMQ 11 | // https://github.com/squaremo/rabbit.js 12 | 13 | amqp.connect('amqp://localhost').then(function(conn) { 14 | process.once('SIGINT', function() { 15 | conn.close(); 16 | process.exit(0); 17 | }); 18 | 19 | return conn.createChannel().then(function(channel) { 20 | 21 | var cityOk = channel.assertQueue(CITY_UPDATES_QUEUE, {durable: false}); 22 | 23 | cityOk = cityOk.then(function(_qok) { 24 | return channel.consume(CITY_UPDATES_QUEUE, function(msg) { 25 | var content = JSON.parse(msg.content) 26 | console.log(" [City] Received:"); 27 | console.log(util.inspect(content, {depth: 3})); 28 | 29 | city.update(content); 30 | 31 | channel.ack(msg); 32 | }); 33 | }); 34 | 35 | cityOk.then(function(_consumeOk) { 36 | console.log(' [*] AMQP Consumer waiting for messages'); 37 | }); 38 | 39 | var clientOk = channel.assertQueue(CLIENT_UPDATES_QUEUE, {durable: false}); 40 | 41 | cityOk = cityOk.then(function(_qok) { 42 | return channel.consume(CLIENT_UPDATES_QUEUE, function(msg) { 43 | 44 | if (!msg.content) return; 45 | 46 | var update = JSON.parse(msg.content); 47 | 48 | clients.get(update.id, function(err, client) { 49 | if (client) client.update(update); 50 | }); 51 | 52 | channel.ack(msg); 53 | }); 54 | }); 55 | 56 | 57 | }); 58 | }).then(null, console.warn); 59 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === "production") { 2 | require('nodetime').profile({ 3 | accountKey: 'a0df5534478dd2873fcc0789e958749f2a356908', 4 | appName: 'InstaCab Dispatcher' 5 | }); 6 | 7 | require("bugsnag").register("889ee967ff69e8a6def329190b410677"); 8 | } 9 | 10 | var Dispatcher = require('./dispatch'), 11 | // agent = require('webkit-devtools-agent'), 12 | WebSocketServer = require('ws').Server, 13 | express = require('express'), 14 | inspect = require('util').inspect, 15 | util = require('util'), 16 | cors = require('cors'), 17 | apiBackend = require('./backend'), 18 | async = require('async'), 19 | db = require('./mongo_client'), 20 | amqpConsumer = require('./amqp_consumer'); 21 | 22 | var dispatcher = new Dispatcher(); 23 | 24 | dispatcher.load(function(err) { 25 | if (err) return console.log(err); 26 | 27 | var app = express(); 28 | var port = process.env.PORT || 9000; 29 | var server = app.listen(port); 30 | console.log(' [*] Dispatcher started on port %d', port); 31 | 32 | // Websockets 33 | var wss = new WebSocketServer({ server: server }); 34 | wss.on('connection', function(connection) { 35 | 36 | connection.on('message', function(data) { 37 | dispatcher.processMessage(data, connection); 38 | }); 39 | 40 | connection.on('close', function() { 41 | connection.removeAllListeners(); 42 | connection = null; 43 | }); 44 | 45 | connection.on('error', function(reason, code){ 46 | console.log('socket error: reason ' + reason + ', code ' + code); 47 | connection.removeAllListeners(); 48 | connection = null; 49 | }) 50 | }); 51 | 52 | 53 | // Middleware 54 | app.use(express.json()); 55 | app.use(cors()); 56 | app.use(app.router); 57 | app.use(function(err, req, res, next) { 58 | console.error(err.stack); 59 | 60 | res.send('500', { messageType: 'Error', text: err.message }); 61 | }); 62 | 63 | // create index: 64 | // key, unique, callback 65 | db.collection('mobile_events').ensureIndex({ "location": "2d" }, false, function(err, replies){}); 66 | db.collection('driver_events').ensureIndex({ "location": "2d" }, false, function(err, replies){}); 67 | 68 | // Events 69 | app.post('/mobile/event', function(req, resp) { 70 | // console.log(req.body); 71 | 72 | db.collection('mobile_events').insert( req.body, function(err, replies){ 73 | if (err) console.log(err); 74 | }); 75 | 76 | resp.writeHead(200, { 'Content-Type': 'text/plain' }); 77 | resp.end(); 78 | 79 | if (req.body.eventName === "NearestCabRequest" && req.body.parameters.reason === "openApp") { 80 | apiBackend.clientOpenApp(req.body.parameters.clientId || req.body.clientId); 81 | } 82 | }); 83 | 84 | var clientRepository = require('./models/client').repository; 85 | 86 | // TODO: Это должен быть отдельный от Диспетчера Node.js процесс 87 | // 1) Нужен набор служб который запускается как один организм в котором службы сотрудничают друг с другом 88 | // 2) Нужен процесс который будет перезапускать умершие службы, да Forever должен справиться 89 | 90 | // TODO: Это должно делаться через Redis, хранишь данные в Redis, 91 | // потом читаешь их и кэшируешь в памяти через request-redis-cache, затем с Web интерфейса можешь 92 | // обновить данные Клиента, Водителя в Redis и послать сигнал в Redis чтобы Диспетчер прочитал обновленные данные из Redis 93 | // 94 | // State management 95 | app.put('/clients/:id', function(req, resp) { 96 | 97 | clientRepository.get(req.body.id, function(err, client) { 98 | if (err) return console.log(err); 99 | 100 | client.update(req.body); 101 | }); 102 | 103 | resp.end(); 104 | }); 105 | 106 | var filterClientIds = [ 29, 31, 35, 36, 49, 63, 60, 67 ]; 107 | 108 | // Query demand 109 | app.get('/query/pings', function(req, resp) { 110 | var filter = { 111 | // location: { 112 | // $near: [39.192151, 51.672448], // Center of the Voronezh 113 | // $maxDistance: 80 * 1000 // 40 km 114 | // }, 115 | eventName: 'NearestCabRequest', 116 | 'parameters.reason': 'openApp', 117 | 'parameters.clientId': { $nin: filterClientIds } // filter out Pavel Tisunov and Mikhail Zhizhenko 118 | }; 119 | 120 | db.collection('mobile_events').find(filter).toArray(function(err, items) { 121 | if (err) return resp.end(JSON.stringify({pings: ""})); 122 | 123 | var pings = async.map(items, function(item, callback) { 124 | 125 | callback(null, { 126 | id: item._id, 127 | clientId: item.parameters.clientId || item.clientId, 128 | longitude: item.location[0] || 0, 129 | latitude: item.location[1] || 0, 130 | epoch: item.epoch, 131 | verticalAccuracy: item.parameters.locationVerticalAccuracy, 132 | horizontalAccuracy: item.parameters.locationHorizontalAccuracy 133 | }); 134 | 135 | }, function(err, result) { 136 | resp.end(JSON.stringify({pings: result})); 137 | }); 138 | }); 139 | 140 | }); 141 | 142 | app.get('/query/pickup_requests', function(req, resp) { 143 | var filter = { 144 | // TODO: Сделать миграцию, позже переименовать в базе PickupRequest -> RequestVehicleRequest 145 | eventName: { $in: ['RequestVehicleRequest', 'PickupRequest']}, 146 | 'parameters.clientId': { $nin: filterClientIds } // filter out Pavel Tisunov and Mikhail Zhizhenko 147 | }; 148 | 149 | // TODO: Сделать миграцию, перенести clientId из parameters.clientId в root.clientId 150 | 151 | db.collection('mobile_events').find(filter).toArray(function(err, items) { 152 | if (err) return resp.end(JSON.stringify({pickup_requests: ""})); 153 | 154 | var pings = async.map(items, function(item, callback) { 155 | 156 | callback(null, { 157 | id: item._id, 158 | clientId: item.parameters.clientId || item.clientId, 159 | longitude: item.location[0], 160 | latitude: item.location[1], 161 | epoch: item.epoch, 162 | verticalAccuracy: item.parameters.locationVerticalAccuracy, 163 | horizontalAccuracy: item.parameters.locationHorizontalAccuracy 164 | }); 165 | 166 | }, function(err, result) { 167 | resp.end(JSON.stringify({pickup_requests: result})); 168 | }); 169 | }); 170 | 171 | }); 172 | 173 | }); -------------------------------------------------------------------------------- /backend.js: -------------------------------------------------------------------------------- 1 | var Driver = require("./models/driver").Driver, 2 | Client = require("./models/client").Client, 3 | driverRepository = require('./models/driver').repository, 4 | clientRepository = require('./models/client').repository, 5 | request = require("request"), 6 | util = require("util"), 7 | MessageFactory = require("./messageFactory"), 8 | config = require('konfig')(), 9 | _ = require('underscore'); 10 | 11 | function Backend() { 12 | 13 | } 14 | 15 | var backendUrl = 'http://' + config.app.BackendApiHost + ':' + config.app.BackendApiPort; 16 | var backendApiUrl = backendUrl + '/api/v1'; 17 | 18 | function login(url, email, password, deviceId, constructor, repository, callback) { 19 | request.post(url, { form: {email: email, password: password} }, function (error, response, body) { 20 | // network error 21 | if (error) return callback(error); 22 | 23 | try { 24 | var properties = JSON.parse(body); 25 | util.inspect(properties, {colors: true}); 26 | } catch (e) { 27 | console.log(e.message); 28 | return callback(new Error("Техническая ошибка входа. Уже работаем над ней.")); 29 | } 30 | 31 | // authentication error 32 | if (response.statusCode !== 200) return callback(new Error(properties['error'] || body)); 33 | 34 | // set user properties 35 | repository.get(properties.id, function(err, user) { 36 | if (err) { 37 | user = new constructor(); 38 | } 39 | // case of a 2nd login with same credentials 40 | else if (user.connected && user.deviceId !== deviceId) { 41 | return callback(new Error("Повторный вход с указанными параметрами запрещен.")); 42 | } 43 | 44 | _.extend(user, properties); 45 | callback(null, user); 46 | }); 47 | }); 48 | } 49 | 50 | Backend.prototype.loginDriver = function(email, password, deviceId, callback) { 51 | login(backendUrl + '/api/v1/drivers/sign_in', email, password, deviceId, Driver, driverRepository, callback); 52 | } 53 | 54 | Backend.prototype.loginClient = function(email, password, deviceId, callback) { 55 | login(backendUrl + '/api/v1/sign_in', email, password, deviceId, Client, clientRepository, callback); 56 | } 57 | 58 | // TODO: Сделать через AMQP 59 | Backend.prototype.signupClient = function(signupInfo, callback) { 60 | request.post(backendUrl + '/api/v1/sign_up', { form: signupInfo }, function (error, response, body) { 61 | // network error 62 | if (error) return callback(error); 63 | 64 | console.log(body); 65 | try { 66 | var data = JSON.parse(body); 67 | } catch (e) { 68 | console.log(e.message); 69 | return callback(new Error("Техническая ошибка входа. Уже работаем над ней.")); 70 | } 71 | 72 | console.log('Response statusCode = ' + response.statusCode); 73 | 74 | // if response not HTTP 201 Created 75 | if (response.statusCode !== 201) { 76 | 77 | var apiResponse = { 78 | error: { statusCode: response.statusCode }, 79 | data: data.errors 80 | } 81 | 82 | // Generate API response as expected by client app 83 | return callback(null, null, { messageType: 'Error', apiResponse: apiResponse }); 84 | } 85 | 86 | // set user properties 87 | var client = new Client(); 88 | // TODO: Передать данные в конструктор и там их присвоить 89 | _.extend(client, data.client); 90 | 91 | callback(null, client, null); 92 | }); 93 | } 94 | 95 | function tripToJson(trip) { 96 | var tripData = {}; 97 | 98 | trip.getSchema().forEach(function(prop) { 99 | if (trip[prop]) { 100 | tripData[prop] = trip[prop]; 101 | } 102 | }); 103 | 104 | return tripData; 105 | } 106 | 107 | // TODO: Сделать через AMQP 108 | Backend.prototype.addTrip = function(trip, callback) { 109 | request.post(backendUrl + '/api/v1/trips', { json: {trip: tripToJson(trip)} }, function (error, response, body) { 110 | callback(error); 111 | }); 112 | } 113 | 114 | // TODO: Сделать через AMQP 115 | Backend.prototype.billTrip = function(trip, callback) { 116 | request.post(backendUrl + '/api/v1/trips/bill', { json: {trip: tripToJson(trip)} }, function (error, response, body) { 117 | // network error 118 | if (error) return callback(error); 119 | 120 | callback(null, body['fare_billed_to_card'], body['fare'], body['paid_by_card']); 121 | }); 122 | } 123 | 124 | // TODO: Сделать через AMQP 125 | Backend.prototype.rateDriver = function(tripId, rating, feedback, callback) { 126 | var payload = { 127 | trip: { rating: rating, feedback: feedback } 128 | }; 129 | 130 | request.put(backendUrl + '/api/v1/trips/' + tripId + '/rate_driver', { json: payload }, function (error, response, body) { 131 | callback(); 132 | }); 133 | } 134 | 135 | // TODO: Сделать через AMQP 136 | Backend.prototype.rateClient = function(tripId, rating, callback) { 137 | var payload = { 138 | trip: { rating: rating } 139 | }; 140 | 141 | request.put(backendUrl + '/api/v1/trips/' + tripId + '/rate_client', { json: payload }, function (error, response, body) { 142 | callback(); 143 | }); 144 | } 145 | 146 | // TODO: Сделать через AMQP 147 | // apiParameters: 148 | // { password: 'fwfweewfwe', 149 | // mobile: '+7 (920) 213-30-56', 150 | // email: 'email@domain.ru' }, 151 | // apiUrl: '/clients/validate', 152 | // apiMethod: 'POST' 153 | Backend.prototype.apiCommand = function(client, message, callback) { 154 | request( 155 | { method: message.apiMethod, 156 | uri: backendApiUrl + message.apiUrl, 157 | form: message.apiParameters 158 | }, 159 | function(error, response, body) { 160 | var apiResponse = { 161 | error: { 162 | statusCode: response.statusCode 163 | } 164 | }; 165 | 166 | if (error) { 167 | apiResponse.error.message = error.message; 168 | } 169 | else if (body) { 170 | try { 171 | apiResponse.data = JSON.parse(body); 172 | } 173 | catch(e) { /* ignore */ } 174 | } 175 | 176 | callback(null, MessageFactory.createClientOK(client, { apiResponse: apiResponse })); 177 | } 178 | ); 179 | } 180 | 181 | // TODO: Сделать через AMQP 182 | Backend.prototype.smsTripStatusToClient = function(trip, client) { 183 | var payload = { 184 | driver_name: trip.driver.firstName, 185 | driver_rating: trip.driver.rating, 186 | trip_state: trip.state.toLowerCase(), 187 | eta_minutes: trip.eta, 188 | vehicle_view_id: trip.vehicleViewId 189 | }; 190 | 191 | request.post(backendUrl + '/api/v1/clients/' + client.id + '/sms', { json: payload }, function (error, response, body) { 192 | if (error) console.log(error); 193 | 194 | }); 195 | } 196 | 197 | Backend.prototype.listVehicles = function(driver, callback) { 198 | request.get(backendUrl + '/api/v1/drivers/' + driver.id + '/vehicles', function (error, response, body) { 199 | if (error) console.log(error); 200 | 201 | try { 202 | var response = JSON.parse(body); 203 | } catch (e) { 204 | console.log(e.message); 205 | return callback(new Error("Техническая ошибка. Уже работаем над ней.")); 206 | } 207 | 208 | callback(null, response.vehicles); 209 | }); 210 | } 211 | 212 | Backend.prototype.selectVehicle = function(driver, vehicleId, callback) { 213 | request.put(backendUrl + '/api/v1/drivers/' + driver.id + '/select_vehicle', { json: { vehicle_id: vehicleId } }, function (error, response, body) { 214 | // network error 215 | if (error) return callback(error); 216 | 217 | callback(null, body.vehicle); 218 | }); 219 | } 220 | 221 | Backend.prototype.getActiveFare = function(callback) { 222 | request.get(backendUrl + '/api/v1/fares', function (error, response, body) { 223 | if (error) console.log(error); 224 | 225 | try { 226 | var response = JSON.parse(body); 227 | } catch (e) { 228 | console.log(e.message); 229 | return callback(new Error("Техническая ошибка.")); 230 | } 231 | 232 | callback(null, response.fare); 233 | }); 234 | } 235 | 236 | Backend.prototype.requestMobileConfirmation = function(clientId) { 237 | request.put(backendUrl + '/api/v1/clients/' + clientId + '/request_mobile_confirmation', function(error, response, body) { 238 | }); 239 | } 240 | 241 | Backend.prototype.clientOpenApp = function(clientId) { 242 | request.get(backendUrl + '/api/v1/clients/' + clientId + '/open_app', function(error, response, body) { 243 | }); 244 | } 245 | 246 | Backend.prototype.clientRequestPickup = function(clientId, params) { 247 | request.put(backendUrl + '/api/v1/clients/' + clientId + '/request_pickup', { json: params }, function(error, response, body) { 248 | }); 249 | } 250 | 251 | module.exports = new Backend(); -------------------------------------------------------------------------------- /client-app.js: -------------------------------------------------------------------------------- 1 | var login, pingClient, pingLoop, postRequest, request, requestPickup; 2 | 3 | login = { 4 | messageType: "Login", 5 | longitude: 39.122151, 6 | latitude: 51.683448, 7 | email: 'tisunov.pavel@gmail.com', 8 | password: 'securepassword', 9 | app: 'client' 10 | }; 11 | 12 | pingClient = { 13 | messageType: "PingClient", 14 | longitude: 39.122151, 15 | latitude: 51.683448, 16 | app: 'client', 17 | }; 18 | 19 | requestPickup = { 20 | messageType: "Pickup", 21 | longitude: 39.122151, 22 | latitude: 51.683448, 23 | app: 'client', 24 | location: { 25 | streetAddress: "9 Января, 302", 26 | region: "Коминтерновский район", 27 | city: "Воронеж", 28 | longitude: 39.122151, 29 | latitude: 51.683448 30 | } 31 | }; 32 | 33 | cancelPickup = { 34 | messageType: "PickupCanceledClient", 35 | longitude: 39.122151, 36 | latitude: 51.683448, 37 | app: 'client', 38 | }; 39 | 40 | var WebSocket = require('faye-websocket'), 41 | client = new WebSocket.Client('ws://localhost:9000/'); 42 | 43 | var timeId; 44 | 45 | client.on('open', function(event) { 46 | console.log('WebSocket client connected'); 47 | 48 | client.sendWithLog = function(message) { 49 | console.log('Sending ' + message.messageType); 50 | console.log(message); 51 | this.send(JSON.stringify(message)); 52 | }; 53 | 54 | client.sendWithLog(login); 55 | 56 | if (timeId) clearInterval(timerId); 57 | 58 | // timeId = setInterval(function() { 59 | // client.sendWithLog(pingClient); 60 | // }, 10000); 61 | }); 62 | 63 | var clientId; 64 | 65 | client.on('message', function(event) { 66 | console.log("Received: " + event.data); 67 | 68 | try { 69 | var response = JSON.parse(event.data); 70 | } 71 | catch(e) { 72 | console.log(e); 73 | return; 74 | } 75 | 76 | switch (response.messageType) { 77 | case 'Login': 78 | clientId = response.client.id; 79 | break; 80 | 81 | case 'ConfirmPickup': 82 | // cancelPickup.tripId = response.trip.id; 83 | // client.sendWithLog(cancelPickup); 84 | break; 85 | 86 | } 87 | }); 88 | 89 | setTimeout(function() { 90 | requestPickup.id = clientId; 91 | client.sendWithLog(requestPickup); 92 | }, 1000); 93 | 94 | 95 | client.on('close', function(event) { 96 | console.log('Connection Closed', event.code, event.reason); 97 | setTimeout(function() { 98 | client = new WebSocket.Client('ws://localhost:9000/'); 99 | }, 500); 100 | }); -------------------------------------------------------------------------------- /config/app.yml: -------------------------------------------------------------------------------- 1 | default: 2 | BackendApiHost: localhost 3 | BackendApiPort: 3000 4 | Schedule: 5 | "0": 6 | name: 18:00 до 22:00 7 | timeRanges: [ {start: 18, end: 22} ] 8 | "1": 9 | name: 18:00 до 22:00 10 | timeRanges: [ 11 | # {start: 7, end: 10}, 12 | {start: 18, end: 22} 13 | ] 14 | "2": 15 | name: 18:00 до 22:00 16 | timeRanges: [ 17 | # {start: 7, end: 10}, 18 | {start: 18, end: 22} 19 | ] 20 | "3": 21 | name: 18:00 до 22:00 22 | timeRanges: [ 23 | # {start: 7, end: 10}, 24 | {start: 18, end: 22} 25 | ] 26 | "4": 27 | name: 18:00 до 22:00 28 | timeRanges: [ 29 | # {start: 7, end: 10}, 30 | {start: 18, end: 22} 31 | ] 32 | # Friday 33 | "5": 34 | name: 18:00 до 24:00 35 | timeRanges: [ 36 | # {start: 7, end: 10}, 37 | {start: 18, end: 24}, 38 | ] 39 | 40 | # Saturday 41 | "6": 42 | name: 18:00 до 24:00 43 | timeRanges: [ {start: 18, end: 24} ] 44 | 45 | 46 | 47 | development: 48 | 49 | production: &production 50 | BackendApiHost: www.instacab.ru 51 | BackendApiPort: 80 52 | 53 | staging: 54 | <<: *production 55 | -------------------------------------------------------------------------------- /config/deploy.rb: -------------------------------------------------------------------------------- 1 | # config valid only for Capistrano 3.1 2 | # lock '3.1.0' 3 | 4 | # Deploying Node applications with Capistrano, GitHub, Nginx and Upstart 5 | # http://www.technology-ebay.de/the-teams/mobile-de/blog/deploying-node-applications-with-capistrano-github-nginx-and-upstart.html 6 | 7 | set :application, 'dispatcher' 8 | set :repo_url, 'git@bitbucket.org:tisunov/dispatcher.git' 9 | set :repository, 'origin' 10 | 11 | # Default deploy_to directory is /var/www/my_app 12 | set :deploy_to, "/home/deploy/apps/dispatcher" 13 | 14 | set :user, "deploy" 15 | 16 | set :scm, :git 17 | 18 | # Default value for :format is :pretty 19 | # set :format, :pretty 20 | 21 | # Default value for :log_level is :debug 22 | # set :log_level, :debug 23 | 24 | # Default value for :pty is false 25 | # set :pty, true 26 | 27 | # Default value for :linked_files is [] 28 | # set :linked_files, %w{config/database.yml} 29 | 30 | # Default value for linked_dirs is [] 31 | # set :linked_dirs, %w{bin log tmp/pids tmp/cache tmp/sockets vendor/bundle public/system} 32 | 33 | # Default value for default_env is {} 34 | # set :default_env, { path: "/opt/ruby/bin:$PATH" } 35 | 36 | set :app_command, "app.js" 37 | 38 | set :default_env, { 39 | 'NODE_ENV' => 'production' 40 | } 41 | 42 | # Default value for keep_releases is 5 43 | # set :keep_releases, 5 44 | 45 | namespace :deploy do 46 | 47 | desc 'Stop dispatcher' 48 | task :stop do 49 | on roles(:app), in: :sequence do 50 | execute '/etc/init.d/forever', "stop" 51 | end 52 | end 53 | 54 | desc 'Start dispatcher' 55 | task :start do 56 | on roles(:app), in: :sequence do 57 | execute '/etc/init.d/forever', "start" 58 | end 59 | end 60 | 61 | desc 'Restart dispatcher' 62 | task :restart do 63 | on roles(:app), in: :sequence do 64 | execute '/etc/init.d/forever', "restart" 65 | end 66 | end 67 | after :publishing, :restart 68 | 69 | desc "Install node modules non-globally" 70 | task :npm_install do 71 | on roles(:app), in: :sequence, wait: 5 do 72 | within release_path do 73 | execute :npm, "install" 74 | end 75 | end 76 | end 77 | after :updated, :npm_install 78 | 79 | # Capistrano task so I don't have manually do git push before cap deploy. 80 | # It includes some error checking to make sure I'm on the right branch (master) and haven't got any uncommitted changes 81 | desc "Push local changes to Git repository" 82 | task :push do 83 | # Check for any local changes that haven't been committed 84 | # Use 'cap deploy:push IGNORE_DEPLOY_RB=1' to ignore changes to this file (for testing) 85 | status = %x(git status --porcelain).chomp 86 | if status != "" 87 | if status !~ %r{^[M ][M ] config/deploy.rb$} 88 | raise "Local git repository has uncommitted changes" 89 | elsif !ENV["IGNORE_DEPLOY_RB"] 90 | # This is used for testing changes to this script without committing them first 91 | # raise "Local git repository has uncommitted changes (set IGNORE_DEPLOY_RB=1 to ignore changes to deploy.rb)" 92 | end 93 | end 94 | 95 | # Check we are on the master branch, so we can't forget to merge before deploying 96 | branch = %x(git branch --no-color 2>/dev/null | sed -e '/^[^*]/d' -e 's/* \\(.*\\)/\\1/').chomp 97 | if branch != "master" && !ENV["IGNORE_BRANCH"] 98 | raise "Not on master branch (set IGNORE_BRANCH=1 to ignore)" 99 | end 100 | 101 | # Push the changes 102 | if ! system "git push #{fetch(:repository)} master" 103 | raise "Failed to push changes to #{fetch(:repository)}" 104 | end 105 | end 106 | 107 | end 108 | 109 | if !ENV["NO_PUSH"] 110 | before "deploy", "deploy:push" 111 | end 112 | 113 | namespace :fake_driver do 114 | desc 'Start fake driver' 115 | task :start do 116 | on roles(:app), in: :sequence do 117 | within release_path do 118 | execute '/usr/local/bin/forever', "start driver-app.js" 119 | end 120 | end 121 | end 122 | 123 | desc 'Stop fake driver' 124 | task :stop do 125 | on roles(:app), in: :sequence do 126 | within release_path do 127 | execute '/usr/local/bin/forever', "stop driver-app.js" 128 | end 129 | end 130 | end 131 | 132 | desc 'Restart fake driver' 133 | task :restart do 134 | on roles(:app), in: :sequence do 135 | within release_path do 136 | execute '/usr/local/bin/forever', "restart driver-app.js" 137 | end 138 | end 139 | end 140 | # after "deploy:publishing", :restart_fake_driver 141 | end -------------------------------------------------------------------------------- /config/deploy/production.rb: -------------------------------------------------------------------------------- 1 | # Simple Role Syntax 2 | # ================== 3 | # Supports bulk-adding hosts to roles, the primary 4 | # server in each group is considered to be the first 5 | # unless any hosts have the primary property set. 6 | # Don't declare `role :all`, it's a meta role 7 | role :app, %w{deploy@instacab.ru} 8 | role :web, %w{deploy@instacab.ru} 9 | role :db, %w{deploy@instacab.ru} 10 | 11 | # Extended Server Syntax 12 | # ====================== 13 | # This can be used to drop a more detailed server 14 | # definition into the server list. The second argument 15 | # something that quacks like a hash can be used to set 16 | # extended properties on the server. 17 | server 'instacab.ru', user: 'deploy', roles: %w{web app} 18 | 19 | # you can set custom ssh options 20 | # it's possible to pass any option but you need to keep in mind that net/ssh understand limited list of options 21 | # you can see them in [net/ssh documentation](http://net-ssh.github.io/net-ssh/classes/Net/SSH.html#method-c-start) 22 | # set it globally 23 | set :ssh_options, { 24 | forward_agent: true, 25 | } 26 | # and/or per server 27 | # server 'example.com', 28 | # user: 'user_name', 29 | # roles: %w{web app}, 30 | # ssh_options: { 31 | # user: 'user_name', # overrides user setting above 32 | # keys: %w(/home/user_name/.ssh/id_rsa), 33 | # forward_agent: false, 34 | # auth_methods: %w(publickey password) 35 | # # password: 'please use keys' 36 | # } 37 | # setting per server overrides global ssh_options 38 | -------------------------------------------------------------------------------- /config/deploy/staging.rb: -------------------------------------------------------------------------------- 1 | # Simple Role Syntax 2 | # ================== 3 | # Supports bulk-adding hosts to roles, the primary 4 | # server in each group is considered to be the first 5 | # unless any hosts have the primary property set. 6 | # Don't declare `role :all`, it's a meta role 7 | role :app, %w{deploy@example.com} 8 | role :web, %w{deploy@example.com} 9 | role :db, %w{deploy@example.com} 10 | 11 | # Extended Server Syntax 12 | # ====================== 13 | # This can be used to drop a more detailed server 14 | # definition into the server list. The second argument 15 | # something that quacks like a hash can be used to set 16 | # extended properties on the server. 17 | server 'example.com', user: 'deploy', roles: %w{web app}, my_property: :my_value 18 | 19 | # you can set custom ssh options 20 | # it's possible to pass any option but you need to keep in mind that net/ssh understand limited list of options 21 | # you can see them in [net/ssh documentation](http://net-ssh.github.io/net-ssh/classes/Net/SSH.html#method-c-start) 22 | # set it globally 23 | # set :ssh_options, { 24 | # keys: %w(/home/rlisowski/.ssh/id_rsa), 25 | # forward_agent: false, 26 | # auth_methods: %w(password) 27 | # } 28 | # and/or per server 29 | # server 'example.com', 30 | # user: 'user_name', 31 | # roles: %w{web app}, 32 | # ssh_options: { 33 | # user: 'user_name', # overrides user setting above 34 | # keys: %w(/home/user_name/.ssh/id_rsa), 35 | # forward_agent: false, 36 | # auth_methods: %w(publickey password) 37 | # # password: 'please use keys' 38 | # } 39 | # setting per server overrides global ssh_options 40 | -------------------------------------------------------------------------------- /config/runtime.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /disable.sh: -------------------------------------------------------------------------------- 1 | sudo ipfw delete 1 2 | sudo ipfw delete 2 -------------------------------------------------------------------------------- /dispatch.js: -------------------------------------------------------------------------------- 1 | var async = require('async'), 2 | util = require('util'), 3 | _ = require('underscore'), 4 | inspect = require('util').inspect, 5 | apiBackend = require('./backend'), 6 | Trip = require("./models/trip").Trip, 7 | tripRepository = require('./models/trip').repository, 8 | driverRepository = require('./models/driver').repository, 9 | clientRepository = require('./models/client').repository, 10 | Driver = require("./models/driver").Driver, 11 | Client = require("./models/client").Client, 12 | city = require("./models/city"), 13 | redis = require("redis").createClient(), 14 | ErrorCodes = require("./error_codes"), 15 | MessageFactory = require("./messageFactory"), 16 | mongoClient = require('./mongo_client'); 17 | 18 | function Dispatcher() { 19 | this.driverEventCallback = this._clientsUpdateNearbyDrivers.bind(this); 20 | this.channelClients = {}; 21 | 22 | redis.subscribe('channel:drivers'); 23 | redis.subscribe('channel:clients'); 24 | redis.subscribe('channel:trips'); 25 | 26 | // Broadcast message to clients 27 | redis.on('message', function(channel, message) { 28 | channel = channel.split(':')[1]; 29 | if (!this.channelClients[channel]) return; 30 | 31 | this.channelClients[channel].forEach(function(connection){ 32 | var data = JSON.stringify({channel: channel, data: JSON.parse(message)}); 33 | 34 | try { 35 | connection.send(data); 36 | } 37 | catch(e) { 38 | connection.close(); 39 | }; 40 | }, this); 41 | 42 | }.bind(this)); 43 | } 44 | 45 | Dispatcher.prototype = { 46 | Login: function(context, callback) { 47 | async.waterfall([ 48 | function(nextFn) { 49 | apiBackend.loginClient(context.message.email, context.message.password, context.message.deviceId, nextFn); 50 | }, 51 | function(client, nextFn) { 52 | client.login(context, nextFn); 53 | } 54 | ], callback); 55 | }, 56 | 57 | SignUpClient: function(context, callback) { 58 | async.waterfall([ 59 | function(nextFn) { 60 | var signUpInfo = { 61 | first_name: context.message.firstName, 62 | last_name: context.message.lastName, 63 | mobile: context.message.mobile, 64 | password: context.message.password, 65 | email: context.message.email 66 | } 67 | 68 | apiBackend.signupClient({ user: signUpInfo }, nextFn); 69 | }, 70 | function(client, response, nextFn) { 71 | if (client) 72 | client.login(context, nextFn); 73 | else 74 | nextFn(null, response); 75 | } 76 | // error, result 77 | ], callback); 78 | }, 79 | 80 | PingClient: function(context, callback) { 81 | clientRepository.get(context.message.id, function(err, client) { 82 | if (err) return callback(err); 83 | 84 | client.ping(context, callback); 85 | }); 86 | }, 87 | 88 | Pickup: function(context, callback) { 89 | clientRepository.get(context.message.id, function(err, client) { 90 | if (err) return callback(err); 91 | 92 | client.pickup(context, callback); 93 | }); 94 | }, 95 | 96 | // TODO: Записать в Client lastEstimatedTrip расчет поездки 97 | // И сохранять для каждого клиента это в базе 98 | SetDestination: function(context, callback) { 99 | clientRepository.get(context.message.id, function(err, client) { 100 | if (err) return callback(err); 101 | 102 | city.estimateFare(client, context.message, callback); 103 | }); 104 | }, 105 | 106 | PickupCanceledClient: function(context, callback) { 107 | clientRepository.get(context.message.id, function(err, client) { 108 | if (err) return callback(err); 109 | 110 | client.cancelPickup(context, callback); 111 | }); 112 | }, 113 | 114 | // TODO: Убрать когда выпустишь новую версию iOS Client 115 | CancelTripClient: function(context, callback) { 116 | this.PickupCanceledClient(context, callback); 117 | }, 118 | 119 | RatingDriver: function(context, callback) { 120 | tripRepository.get(context.message.tripId, function(err, trip){ 121 | if (err) return callback(err); 122 | 123 | trip.clientRateDriver(context, callback); 124 | }); 125 | }, 126 | 127 | LoginDriver: function(context, callback) { 128 | apiBackend.loginDriver(context.message.email, context.message.password, context.message.deviceId, function(err, driver){ 129 | if (err) return callback(err); 130 | 131 | this._subscribeToDriverEvents(driver); 132 | 133 | callback(null, driver.login(context)); 134 | }.bind(this)); 135 | }, 136 | 137 | LogoutDriver: function(context, callback) { 138 | driverRepository.get(context.message.id, function(err, driver) { 139 | if (err) return callback(err); 140 | 141 | callback(null, driver.logout(context)); 142 | }); 143 | }, 144 | 145 | OffDutyDriver: function(context, callback) { 146 | driverRepository.get(context.message.id, function(err, driver) { 147 | if (err) return callback(err); 148 | 149 | callback(null, driver.offDuty(context)); 150 | }); 151 | }, 152 | 153 | OnDutyDriver: function(context, callback) { 154 | driverRepository.get(context.message.id, function(err, driver) { 155 | if (err) return callback(err, null); 156 | 157 | callback(null, driver.onDuty(context)); 158 | }); 159 | }, 160 | 161 | PingDriver: function(context, callback) { 162 | driverRepository.get(context.message.id, function(err, driver) { 163 | if (err) return callback(err); 164 | 165 | callback(null, driver.ping(context)); 166 | }); 167 | }, 168 | 169 | ConfirmPickup: function(context, callback) { 170 | tripRepository.get(context.message.tripId, function(err, trip){ 171 | if (err) return callback(err); 172 | 173 | trip.confirm(context, callback); 174 | }); 175 | }, 176 | 177 | ArrivingNow: function(context, callback) { 178 | tripRepository.get(context.message.tripId, function(err, trip){ 179 | if (err) return callback(err); 180 | 181 | trip.driverArriving(context, callback); 182 | }); 183 | }, 184 | 185 | BeginTripDriver: function(context, callback) { 186 | tripRepository.get(context.message.tripId, function(err, trip){ 187 | if (err) return callback(err); 188 | 189 | trip.driverBegin(context, callback); 190 | }); 191 | }, 192 | 193 | PickupCanceledDriver: function(context, callback) { 194 | driverRepository.get(context.message.id, function(err, driver) { 195 | if (err) return callback(err); 196 | 197 | driver.cancelPickup(context, callback); 198 | }); 199 | }, 200 | 201 | EndTrip: function(context, callback) { 202 | tripRepository.get(context.message.tripId, function(err, trip) { 203 | if (err) return callback(err); 204 | 205 | trip.driverEnd(context, callback); 206 | }); 207 | }, 208 | 209 | ListVehicles: function(context, callback) { 210 | driverRepository.get(context.message.id, function(err, driver) { 211 | if (err) return callback(err); 212 | 213 | driver.listVehicles(callback); 214 | }); 215 | }, 216 | 217 | SelectVehicle: function(context, callback) { 218 | driverRepository.get(context.message.id, function(err, driver) { 219 | if (err) return callback(err); 220 | 221 | driver.selectVehicle(context, callback); 222 | }); 223 | }, 224 | 225 | RatingClient: function(context, callback) { 226 | tripRepository.get(context.message.tripId, function(err, trip){ 227 | if (err) return callback(err); 228 | 229 | trip.driverRateClient(context, callback); 230 | }); 231 | }, 232 | 233 | ApiCommand: function(context, callback) { 234 | if (context.message.id) { 235 | clientRepository.get(context.message.id, function(err, client) { 236 | if (err) return callback(err); 237 | 238 | apiBackend.apiCommand(client, context.message, callback); 239 | }); 240 | } 241 | else 242 | apiBackend.apiCommand(null, context.message, callback); 243 | }, 244 | 245 | Subscribe: function(context, callback) { 246 | if (!context.message.channel) return callback(new Error('channel could not be empty')); 247 | 248 | // Client subscriptions management 249 | this.channelClients[context.message.channel] = this.channelClients[context.message.channel] || []; 250 | var clients = this.channelClients[context.message.channel]; 251 | clients.push(context.connection); 252 | 253 | console.log("Subscribe to " + context.message.channel); 254 | console.log("Channel " + context.message.channel + " has " + clients.length + " subscriber"); 255 | 256 | // Remove disconnected clients 257 | context.connection.once('close', function() { 258 | index = clients.indexOf(context.connection); 259 | if (index > -1) { 260 | console.log('Remove subscriber from ' + context.message.channel); 261 | clients.splice(index, 1); 262 | } 263 | }); 264 | 265 | // Push initial state 266 | if (context.message.channel === 'drivers') { 267 | Driver.publishAll(); 268 | } else if (context.message.channel === 'clients') { 269 | Client.publishAll(); 270 | } else if (context.message.channel === 'trips') { 271 | Trip.publishAll(); 272 | } 273 | } 274 | } 275 | 276 | function responseWithError(text, errorCode){ 277 | var msg = MessageFactory.createError(text, errorCode); 278 | 279 | console.log('Sending response:'); 280 | console.log(util.inspect(msg, {depth: 3})); 281 | 282 | try { 283 | this.send(JSON.stringify(msg)); 284 | } 285 | catch(e) { 286 | 287 | }; 288 | } 289 | 290 | Dispatcher.prototype._parseJSONData = function(data, connection) { 291 | var message; 292 | try { 293 | message = JSON.parse(data); 294 | console.log(util.inspect(message, {depth: 3, colors: true})); 295 | } 296 | catch(e) { 297 | responseWithError.call(connection, e.message); 298 | } 299 | 300 | return message; 301 | } 302 | 303 | Dispatcher.prototype._findMessageHandler = function(message, connection) { 304 | if (message.app !== 'client' && message.app !== 'driver' && message.app !== 'god') { 305 | return responseWithError.call(connection, 'Unknown client app: ' + message.app); 306 | } 307 | 308 | var handler = this.__proto__[message.messageType]; 309 | if (!handler) { 310 | return responseWithError.call(connection, 'Unsupported message type: ' + message.messageType); 311 | } 312 | 313 | return handler; 314 | } 315 | 316 | // Update all clients except the one requested pickup 317 | Dispatcher.prototype._clientsUpdateNearbyDrivers = function(driver, clientRequestedPickup) { 318 | var skipClientId = clientRequestedPickup ? clientRequestedPickup.id : null; 319 | 320 | if (!driver.connected) driver.removeAllListeners(); 321 | 322 | clientRepository.each(function(client) { 323 | if (client.id === skipClientId) return; 324 | 325 | client.sendDriversNearby(); 326 | }); 327 | } 328 | 329 | // Subscribe to driver events (1 time) 330 | Dispatcher.prototype._subscribeToDriverEvents = function(driver) { 331 | driver.removeAllListeners(); 332 | 333 | _.each(['connect', 'disconnect', 'available', 'unavailable'], function(eventName){ 334 | driver.on(eventName, this.driverEventCallback); 335 | }.bind(this)); 336 | } 337 | 338 | Dispatcher.prototype._accessWithoutToken = function(methodName) { 339 | return ["Login", "ApiCommand", "LoginDriver", "SignUpClient", "Subscribe"].indexOf(methodName) > -1; 340 | } 341 | 342 | Dispatcher.prototype._tokenValid = function(message, connection) { 343 | var user; 344 | if (message.app === "client") { 345 | user = clientRepository.get(message.id); 346 | } 347 | else if (message.app === "driver") { 348 | user = driverRepository.get(message.id); 349 | } 350 | 351 | if (user && !user.isTokenValid(message)) { 352 | responseWithError.call(connection, "Доступ запрещен", ErrorCodes.INVALID_TOKEN); 353 | return false; 354 | } 355 | 356 | return true; 357 | } 358 | 359 | Dispatcher.prototype.load = function(callback) { 360 | var self = this; 361 | async.parallel({ 362 | drivers: driverRepository.all.bind(driverRepository), 363 | clients: clientRepository.all.bind(clientRepository), 364 | trips: tripRepository.all.bind(tripRepository) 365 | }, 366 | function(err, result){ 367 | async.parallel([ 368 | function(next) { 369 | // console.log('Cache ' + result.drivers.length + ' driver(s)'); 370 | async.each(result.drivers, function(driver, cb){ 371 | self._subscribeToDriverEvents(driver); 372 | driver.load(cb); 373 | }, next); 374 | }, 375 | function(next) { 376 | // console.log('Cache ' + result.clients.length + ' client(s)'); 377 | async.each(result.clients, function(client, cb){ 378 | client.load(cb); 379 | }, next); 380 | }, 381 | function(next) { 382 | // console.log('Cache ' + result.trips.length + ' trip(s)'); 383 | async.each(result.trips, function(trip, cb){ 384 | trip.load(function(err) { 385 | if (err) console.log('Error loading trip ' + trip.id + ':' + err); 386 | cb() 387 | }); 388 | }, next); 389 | } 390 | 391 | ], callback); 392 | }); 393 | } 394 | 395 | Dispatcher.prototype.processMessage = function(data, connection) { 396 | console.log("Process message:"); 397 | // console.log(data); 398 | 399 | var message; 400 | if (!(message = this._parseJSONData(data, connection))) return; 401 | 402 | // Find message handler 403 | var messageHandler; 404 | if (!(messageHandler = this._findMessageHandler(message, connection))) return; 405 | 406 | // Validate token 407 | if (!this._accessWithoutToken(message.messageType) && !this._tokenValid(message, connection)) return; 408 | 409 | // Process request and send response 410 | messageHandler.call(this, {message: message, connection: connection}, function(err, result) { 411 | if(err) { 412 | console.log(err.stack); 413 | return responseWithError.call(connection, err.message); 414 | } 415 | 416 | console.log('Sending response:'); 417 | console.log(util.inspect(result, {depth: 3})); 418 | 419 | // Send response 420 | connection.send(JSON.stringify(result), function(err) { 421 | if (err) return console.log(err); 422 | }); 423 | }); 424 | } 425 | 426 | module.exports = Dispatcher; -------------------------------------------------------------------------------- /dispatch.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "path": "/Users/tisunov/dispatch" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /driver-app.js: -------------------------------------------------------------------------------- 1 | var login1 = { 2 | messageType: "LoginDriver", 3 | app: "driver", 4 | email: 'mike@mail.ru', 5 | password: 'securepassword', 6 | latitude: 51.724789, 7 | longitude: 39.171527, 8 | epoch: Math.round(new Date().getTime() / 1000.0), 9 | }; 10 | 11 | var onduty = { 12 | messageType: "OnDutyDriver", 13 | app: "driver", 14 | latitude: 51.674789, 15 | longitude: 39.211527, 16 | epoch: Math.round(new Date().getTime() / 1000.0) 17 | } 18 | 19 | var signOut = { 20 | messageType: "SignOut", 21 | app: "driver", 22 | latitude: 51.68274, 23 | longitude: 39.12119 24 | }; 25 | 26 | var pingDriver = { 27 | messageType: "PingDriver", 28 | app: 'driver', 29 | latitude: 51.674789, 30 | longitude: 39.211527, 31 | epoch: Math.round(new Date().getTime() / 1000.0), 32 | course: 0 33 | }; 34 | 35 | var confirmPickup = { 36 | messageType: "ConfirmPickup", 37 | altitude: 0, 38 | latitude: 51.68274, 39 | longitude: 39.12119, 40 | epoch: Math.round(new Date().getTime() / 1000.0), 41 | app: 'driver', 42 | }; 43 | 44 | var arrivingNow = { 45 | messageType: "ArrivingNow", 46 | altitude: 0, 47 | latitude: 51.68274, 48 | longitude: 39.12119, 49 | epoch: Math.round(new Date().getTime() / 1000.0), 50 | app: 'driver', 51 | }; 52 | 53 | var beginTrip = { 54 | messageType: "BeginTripDriver", 55 | app: 'driver', 56 | latitude: 51.68274, 57 | longitude: 39.12119, 58 | epoch: Math.round(new Date().getTime() / 1000.0) 59 | }; 60 | 61 | var endTrip = { 62 | messageType: "EndTrip", 63 | app: 'driver', 64 | token: 'db1eba81d9d8', 65 | latitude: 51.68274, 66 | longitude: 39.12119, 67 | epoch: Math.round(new Date().getTime() / 1000.0) 68 | }; 69 | 70 | var tripCoordinates = [ 71 | [51.681520, 39.183383], 72 | [51.675932, 39.169736], 73 | [51.670715, 39.161153], 74 | [51.672419, 39.153171], 75 | [51.675719, 39.143300], 76 | [51.677901, 39.136691], 77 | [51.680296, 39.129653], 78 | [51.683448, 39.122151], 79 | ]; 80 | 81 | var WebSocket = require('faye-websocket'), 82 | client = new WebSocket.Client('ws://localhost:9000/'); 83 | 84 | client.on('open', function(event) { 85 | console.log('WebSocket client connected'); 86 | 87 | client.sendWithLog = function(message) { 88 | console.log('Sending ' + message.messageType); 89 | console.log(message); 90 | this.send(JSON.stringify(message)); 91 | }; 92 | 93 | client.sendWithLog(login1); 94 | }); 95 | 96 | 97 | client.on('close', function(event) { 98 | console.log('Connection Closed', event.code, event.reason); 99 | setTimeout(function() { 100 | client = new WebSocket.Client('ws://localhost:9000/'); 101 | }, 500); 102 | }); 103 | 104 | function driveToClient(driverId, tripId, pickupLocation) { 105 | var i = 0; 106 | var timerId = setInterval(function() { 107 | // Send driver coordinates every second 108 | pingDriver.id = driverId; 109 | pingDriver.latitude = tripCoordinates[i][0]; 110 | pingDriver.longitude = tripCoordinates[i][1]; 111 | pingDriver.epoch = Math.round(new Date().getTime() / 1000.0); 112 | pingDriver.token = token; 113 | pingDriver.course = Math.round(Math.random(360) * 100); 114 | client.sendWithLog(pingDriver); 115 | 116 | // Send arriving now 117 | if (i === tripCoordinates.length - 1) { 118 | clearInterval(timerId); 119 | 120 | arrivingNow.id = driverId; 121 | arrivingNow.token = token; 122 | arrivingNow.tripId = tripId; 123 | arrivingNow.latitude = pickupLocation.latitude; 124 | arrivingNow.longitude = pickupLocation.longitude; 125 | arrivingNow.epoch = Math.round(new Date().getTime() / 1000.0); 126 | client.sendWithLog(arrivingNow); 127 | } 128 | 129 | i++; 130 | }, 500); 131 | } 132 | 133 | function driveClient(driverId, callback) { 134 | var i = tripCoordinates.length; 135 | var timerId = setInterval(function() { 136 | i--; 137 | 138 | // Send driver coordinates every second 139 | pingDriver.id = driverId; 140 | pingDriver.latitude = tripCoordinates[i][0]; 141 | pingDriver.longitude = tripCoordinates[i][1]; 142 | pingDriver.epoch = Math.round(new Date().getTime()/1000.0); // in seconds 143 | pingDriver.token = token; 144 | pingDriver.course = Math.round(Math.random(360) * 100); 145 | client.sendWithLog(pingDriver); 146 | 147 | // Send Ping 148 | if (i === 0) { 149 | clearInterval(timerId); 150 | callback(); 151 | } 152 | }, 500); 153 | 154 | } 155 | 156 | var timer, token, justStarted = true; 157 | 158 | // token = "qLfDVxnRdkVM6ywULEaR"; 159 | // driveToClient(10, null, {latitude: 51.683448, longitude: 39.122151}); 160 | 161 | client.on('message', function(event) { 162 | console.log("Received: " + event.data); 163 | 164 | try { 165 | var response = JSON.parse(event.data); 166 | } 167 | catch(e) { 168 | console.log(e); 169 | return; 170 | } 171 | 172 | switch (response.messageType) { 173 | case 'OK': 174 | if (response.driver.token) 175 | token = response.driver.token; 176 | 177 | if (response.driver.state === 'PendingRating') { 178 | client.sendWithLog({ 179 | messageType: 'RatingClient', 180 | id: response.driver.id, 181 | tripId: response.driver.tripPendingRating.id, 182 | rating: 5.0, 183 | app: 'driver', 184 | token: token, 185 | latitude: 51.66351, 186 | longitude: 39.185234, 187 | epoch: Math.round(new Date().getTime() / 1000.0) 188 | }); 189 | } 190 | else if (response.driver.state === 'DrivingClient' && justStarted) { 191 | endTrip.tripId = response.trip.id; 192 | endTrip.token = token; 193 | endTrip.epoch = Math.round(new Date().getTime() / 1000.0); 194 | client.sendWithLog(endTrip); 195 | } 196 | else if (response.driver.state === 'OffDuty') { 197 | onduty.id = response.driver.id; 198 | onduty.token = token; 199 | onduty.epoch = Math.round(new Date().getTime() / 1000.0); 200 | client.sendWithLog(onduty); 201 | } 202 | 203 | justStarted = false; 204 | break; 205 | 206 | case 'PickupCanceled': 207 | clearTimeout(timer); 208 | break; 209 | 210 | case 'Pickup': 211 | timer = setTimeout(function() { 212 | confirmPickup.tripId = response.trip.id; 213 | confirmPickup.latitude = 51.681520; 214 | confirmPickup.longitude = 39.183383; 215 | confirmPickup.token = token; 216 | confirmPickup.epoch = Math.round(new Date().getTime() / 1000.0); 217 | client.sendWithLog(confirmPickup); 218 | 219 | driveToClient(response.driver.id, response.trip.id, response.trip.pickupLocation); 220 | 221 | // begin trip after 3 seconds 222 | timer = setTimeout(function() { 223 | // let the Trip begin 224 | beginTrip.tripId = response.trip.id; 225 | beginTrip.token = token; 226 | beginTrip.epoch = Math.round(new Date().getTime() / 1000.0); 227 | client.sendWithLog(beginTrip); 228 | 229 | // send couple of gps points to dispatcher 230 | driveClient(response.driver.id, function() { 231 | // end trip 232 | endTrip.tripId = response.trip.id; 233 | endTrip.token = token; 234 | endTrip.epoch = Math.round(new Date().getTime() / 1000.0); 235 | client.sendWithLog(endTrip); 236 | }); 237 | }, 5000); 238 | 239 | }, 500); 240 | break; 241 | } 242 | }); 243 | -------------------------------------------------------------------------------- /error_codes.js: -------------------------------------------------------------------------------- 1 | exports.INVALID_TOKEN = 1; 2 | exports.NO_DRIVERS_AVAILABLE = 2; -------------------------------------------------------------------------------- /latency.sh: -------------------------------------------------------------------------------- 1 | # Random Packet Loss 1%, delay and throttle localhost:9000 2 | sudo ipfw pipe 1 config bw 50KBytes/s delay 500ms plr 0.01 3 | sudo ipfw add 1 pipe 1 src-port 9000 4 | sudo ipfw add 2 pipe 1 dst-port 9000 -------------------------------------------------------------------------------- /latlon.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Latitude/longitude spherical geodesy formulae & scripts (c) Chris Veness 2002-2012 */ 3 | /* - www.movable-type.co.uk/scripts/latlong.html */ 4 | /* */ 5 | /* Sample usage: */ 6 | /* var p1 = new LatLon(51.5136, -0.0983); */ 7 | /* var p2 = new LatLon(51.4778, -0.0015); */ 8 | /* var dist = p1.distanceTo(p2); // in km */ 9 | /* var brng = p1.bearingTo(p2); // in degrees clockwise from north */ 10 | /* ... etc */ 11 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 12 | 13 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 14 | /* Note that minimal error checking is performed in this example code! */ 15 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 16 | 17 | /** 18 | * Creates a point on the earth's surface at the supplied latitude / longitude 19 | * 20 | * @constructor 21 | * @param {Number} lat: latitude in numeric degrees 22 | * @param {Number} lon: longitude in numeric degrees 23 | * @param {Number} [rad=6371]: radius of earth if different value is required from standard 6,371km 24 | */ 25 | function LatLon(lat, lon, rad) { 26 | if (typeof(rad) == 'undefined') rad = 6371; // earth's mean radius in km 27 | // only accept numbers or valid numeric strings 28 | this._lat = typeof(lat)=='number' ? lat : typeof(lat)=='string' && lat.trim()!='' ? +lat : NaN; 29 | this._lon = typeof(lon)=='number' ? lon : typeof(lon)=='string' && lon.trim()!='' ? +lon : NaN; 30 | this._radius = typeof(rad)=='number' ? rad : typeof(rad)=='string' && trim(lon)!='' ? +rad : NaN; 31 | } 32 | 33 | 34 | /** 35 | * Returns the distance from this point to the supplied point, in km 36 | * (using Haversine formula) 37 | * 38 | * from: Haversine formula - R. W. Sinnott, "Virtues of the Haversine", 39 | * Sky and Telescope, vol 68, no 2, 1984 40 | * 41 | * @param {LatLon} point: Latitude/longitude of destination point 42 | * @param {Number} [precision=4]: no of significant digits to use for returned value 43 | * @returns {Number} Distance in km between this point and destination point 44 | */ 45 | LatLon.prototype.distanceTo = function(point, precision) { 46 | // default 4 sig figs reflects typical 0.3% accuracy of spherical model 47 | if (typeof precision == 'undefined') precision = 4; 48 | 49 | var R = this._radius; 50 | var lat1 = this._lat.toRad(), lon1 = this._lon.toRad(); 51 | var lat2 = point._lat.toRad(), lon2 = point._lon.toRad(); 52 | var dLat = lat2 - lat1; 53 | var dLon = lon2 - lon1; 54 | 55 | var a = Math.sin(dLat/2) * Math.sin(dLat/2) + 56 | Math.cos(lat1) * Math.cos(lat2) * 57 | Math.sin(dLon/2) * Math.sin(dLon/2); 58 | var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 59 | var d = R * c; 60 | return d.toPrecisionFixed(precision); 61 | } 62 | 63 | // ---- extend Number object with methods for converting degrees/radians 64 | 65 | /** Converts numeric degrees to radians */ 66 | if (typeof Number.prototype.toRad == 'undefined') { 67 | Number.prototype.toRad = function() { 68 | return this * Math.PI / 180; 69 | } 70 | } 71 | 72 | /** Converts radians to numeric (signed) degrees */ 73 | if (typeof Number.prototype.toDeg == 'undefined') { 74 | Number.prototype.toDeg = function() { 75 | return this * 180 / Math.PI; 76 | } 77 | } 78 | 79 | /** 80 | * Formats the significant digits of a number, using only fixed-point notation (no exponential) 81 | * 82 | * @param {Number} precision: Number of significant digits to appear in the returned string 83 | * @returns {String} A string representation of number which contains precision significant digits 84 | */ 85 | if (typeof Number.prototype.toPrecisionFixed == 'undefined') { 86 | Number.prototype.toPrecisionFixed = function(precision) { 87 | 88 | // use standard toPrecision method 89 | var n = this.toPrecision(precision); 90 | 91 | // ... but replace +ve exponential format with trailing zeros 92 | n = n.replace(/(.+)e\+(.+)/, function(n, sig, exp) { 93 | sig = sig.replace(/\./, ''); // remove decimal from significand 94 | l = sig.length - 1; 95 | while (exp-- > l) sig = sig + '0'; // append zeros from exponent 96 | return sig; 97 | }); 98 | 99 | // ... and replace -ve exponential format with leading zeros 100 | n = n.replace(/(.+)e-(.+)/, function(n, sig, exp) { 101 | sig = sig.replace(/\./, ''); // remove decimal from significand 102 | while (exp-- > 1) sig = '0' + sig; // prepend zeros from exponent 103 | return '0.' + sig; 104 | }); 105 | 106 | return n; 107 | } 108 | } 109 | 110 | /** Trims whitespace from string (q.v. blog.stevenlevithan.com/archives/faster-trim-javascript) */ 111 | if (typeof String.prototype.trim == 'undefined') { 112 | String.prototype.trim = function() { 113 | return String(this).replace(/^\s\s*/, '').replace(/\s\s*$/, ''); 114 | } 115 | } 116 | 117 | module.exports = LatLon; -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | 3 | // TODO: Обрати внимание на http://nodeguide.ru/doc/modules-you-should-know/hashish/ 4 | 5 | /** 6 | * Basic memory system to get data from it 7 | */ 8 | function Cache(){ 9 | this._map = {}; 10 | } 11 | 12 | 13 | /** 14 | * Set (and replace if needed) a data to store 15 | * @param string key The key reference to store data 16 | * @param mixed data The data to store into this system 17 | */ 18 | Cache.prototype.set = function(key, data){ 19 | this._map[key] = data; 20 | }; 21 | 22 | /** 23 | * Return a data stored, or null if there is nothing 24 | * @param string key The key to store data 25 | * @return mixed The founded data, or null if there is an error 26 | */ 27 | Cache.prototype.get = function(key){ 28 | return this._map[key]; 29 | }; 30 | 31 | /** 32 | * Delete the stored key if it is existing 33 | * @param string key The key to delete associated data 34 | */ 35 | Cache.prototype.remove = function(key){ 36 | if(typeof(this._map[key]) !== "undefined" && this._map[key] !== null){ 37 | // Deleting the map 38 | delete this._map[key]; 39 | } 40 | }; 41 | 42 | Cache.prototype.count = function(){ 43 | return Object.keys(this._map).length; 44 | } 45 | 46 | /** 47 | * Iterate over keys, applying match function 48 | */ 49 | Cache.prototype.map = function(iterator, fn) { 50 | var self = this; 51 | async.map( 52 | Object.keys(this._map), 53 | function(key, cb) { 54 | iterator(self._map[key], cb); 55 | }, 56 | fn 57 | ); 58 | }; 59 | 60 | Cache.prototype.filter = function(iterator, fn) { 61 | async.map( 62 | Object.keys(this._map), 63 | 64 | // map key to item 65 | function(key, cb) { 66 | cb(null, this._map[key]); 67 | }.bind(this), 68 | 69 | // filter items 70 | function(err, items) { 71 | async.filter(items, iterator, fn); 72 | } 73 | ); 74 | }; 75 | 76 | Cache.prototype.each = function(iterator) { 77 | async.each( 78 | Object.keys(this._map), 79 | function(key, callback) { 80 | iterator(this._map[key]); 81 | callback(); 82 | }.bind(this) 83 | ); 84 | } 85 | 86 | Cache.prototype.findOne = function(iterator) { 87 | for (var prop in this._map) { 88 | if (this._map.hasOwnProperty(prop)) { 89 | if (iterator(this._map[prop])) { 90 | return this._map[prop]; 91 | } 92 | } 93 | } 94 | 95 | return null; 96 | } 97 | 98 | module.exports = Cache; -------------------------------------------------------------------------------- /lib/google-distance.js: -------------------------------------------------------------------------------- 1 | var qs = require('querystring'), 2 | http = require('http'); 3 | 4 | function locationToString(location) { 5 | return location.latitude + ',' + location.longitude 6 | } 7 | 8 | exports.get = function(origin, destination, callback) { 9 | var args = { 10 | origin: locationToString(origin), 11 | destination: locationToString(destination), 12 | }; 13 | 14 | var options = { 15 | origins: args.origin, 16 | destinations: args.destination, 17 | mode: args.mode || 'driving', 18 | units: args.units || 'metric', 19 | language: args.language || 'ru', 20 | sensor: args.sensor || true 21 | }; 22 | 23 | if (!options.origins || options.origins === "0,0") { return callback(new Error('Argument Error: Origin is invalid')) } 24 | if (!options.destinations || options.destinations === "0,0") { return callback(new Error('Argument Error: Destination is invalid')) } 25 | 26 | request(options, function(err, result) { 27 | if (err) { 28 | callback(err); 29 | return; 30 | } 31 | var data = result; 32 | if (data.status != 'OK') { 33 | callback(new Error('Google Distance Matrix status error: ' + data.status)); 34 | return; 35 | } 36 | 37 | if (data.rows[0].elements[0].status != 'OK') { 38 | callback(new Error('Google Distance Matrix element status error: ' + data.rows[0].elements[0].status)); 39 | return; 40 | } 41 | 42 | var d = { 43 | distance: data.rows[0].elements[0].distance.text, 44 | distanceKms: data.rows[0].elements[0].distance.value, 45 | duration: data.rows[0].elements[0].duration.text, 46 | durationSeconds: data.rows[0].elements[0].duration.value, 47 | origin: data.origin_addresses[0], 48 | destination: data.destination_addresses[0], 49 | mode: options.mode, 50 | units: options.units, 51 | language: options.language, 52 | avoid: options.avoid, 53 | sensor: options.sensor 54 | }; 55 | return callback(null, d); 56 | }); 57 | } 58 | 59 | 60 | var request = function(options, callback) { 61 | var httpOptions = { 62 | host: 'maps.googleapis.com', 63 | path: '/maps/api/distancematrix/json?' + qs.stringify(options) 64 | }; 65 | 66 | var requestCallback = function(res) { 67 | var json = ''; 68 | 69 | res.on('data', function (chunk) { 70 | json += chunk; 71 | callback(null, JSON.parse(json)); 72 | }); 73 | } 74 | 75 | console.log('Query Google Maps Distance Matrix API'); 76 | console.log('http://' + httpOptions.host + httpOptions.path); 77 | 78 | var req = http.request(httpOptions, requestCallback); 79 | req.on('socket', function (socket) { 80 | socket.setTimeout(1000); 81 | socket.on('timeout', function() { 82 | req.abort(); 83 | }); 84 | }); 85 | 86 | req.on('error', function(err) { 87 | callback(new Error('Request error: ' + err.message)); 88 | }); 89 | req.end(); 90 | } -------------------------------------------------------------------------------- /lib/repository.js: -------------------------------------------------------------------------------- 1 | var redis = require("redis").createClient(), 2 | util = require('util'), 3 | async = require("async"), 4 | Cache = require('./cache'), 5 | _ = require('underscore'); 6 | 7 | var RedisRepository = function(objectContructor) { 8 | var cache = new Cache(); 9 | var modelName = objectContructor.name.toLowerCase(); 10 | 11 | this._defaultSaveCallback = function(err) { 12 | if (err) console.log(err); 13 | }; 14 | 15 | this.save = function(value, callback) { 16 | cache.set(value.id, value); 17 | 18 | var data = modelToArchive(value); 19 | var key = modelName + ':' + value.id; 20 | 21 | redis.set(key, JSON.stringify(data), callback || this._defaultSaveCallback); 22 | }; 23 | 24 | this.get = function(id, callback) { 25 | var value = cache.get(id); 26 | if (!value && callback) return callback(new Error("Внутренняя ошибка. Пожалуйста повторите попытку.")); 27 | 28 | if (callback) 29 | callback(null, value); 30 | else 31 | return value; 32 | }; 33 | 34 | this.remove = function(id, callback) { 35 | console.log('Removing ' + modelName + ':' + id); 36 | 37 | // default callback logs errors 38 | callback = callback || function(err) { 39 | if (err) console.log(err); 40 | }; 41 | 42 | cache.remove(id); 43 | redis.del(modelName + ':' + id, callback); 44 | }; 45 | 46 | this.all = function(callback) { 47 | redis.keys(modelName + ':*', function(err, keys) { 48 | if (keys.length === 0) return callback(err, []); 49 | 50 | redis.mget(keys, function(err, replies){ 51 | if (err) return callback(err, []); 52 | 53 | loadModels(replies, callback); 54 | }); 55 | }); 56 | }; 57 | 58 | this.filter = function(iterator, callback) { 59 | cache.filter(iterator, callback); 60 | }; 61 | 62 | this.each = function(callback) { 63 | cache.each(callback); 64 | }; 65 | 66 | this.generateNextId = function(callback) { 67 | redis.incr('id:' + modelName, function(err, id) { 68 | callback(err, id); 69 | }); 70 | }; 71 | 72 | function modelToArchive(model) { 73 | var data = {}; 74 | 75 | // prepare for serialization 76 | model.getSchema().forEach(function(prop) { 77 | // getter property 78 | if (typeof model[prop] === 'function') { 79 | data[prop] = model[prop].call(model); 80 | } 81 | else if (model[prop]) { 82 | data[prop] = model[prop]; 83 | } 84 | }); 85 | 86 | return data; 87 | } 88 | 89 | function loadModel(json, callback) { 90 | var props = JSON.parse(json); // !!! Can throw 91 | var model = cache.get(props.id); 92 | if (model) return callback(null, model); 93 | 94 | model = new objectContructor(); 95 | _.extend(model, props); 96 | 97 | // keep it in cache 98 | cache.set(model.id, model); 99 | callback(null, model); 100 | }; 101 | 102 | function loadModels(replies, callback) { 103 | var models = []; 104 | 105 | replies.forEach(function(json) { 106 | loadModel(json, function(err, model){ 107 | if (err) console.log(err); 108 | models.push(model); 109 | }); 110 | }); 111 | 112 | callback(null, models); 113 | }; 114 | 115 | }; 116 | 117 | 118 | 119 | module.exports = RedisRepository; -------------------------------------------------------------------------------- /lib/reverse_geocoder.js: -------------------------------------------------------------------------------- 1 | var util = require('util'), 2 | async = require('async'), 3 | GoogleMaps = require('googlemaps'); 4 | 5 | exports.reverseGeocodeLocation = function(location, callback) { 6 | GoogleMaps.reverseGeocode(location.latitude + ',' + location.longitude, function(err, response) { 7 | if (err) { 8 | console.log(err); 9 | return callback(err); 10 | } 11 | if (response.status !== "OK") return callback(new Error(response.status)); 12 | 13 | var city, streetName, streetNumber; 14 | 15 | async.each(response.results, function(address, addressCallback) { 16 | var components = address.address_components; 17 | var component = address.address_components[0]; 18 | if (!component.types) return addressCallback(); 19 | 20 | var componentType = component.types[0]; 21 | if (componentType === "street_number") { 22 | streetNumber = component.long_name; 23 | // extract street name 24 | var routeComponent = components[1]; 25 | if (routeComponent) 26 | streetName = routeComponent.long_name; 27 | } 28 | else if (componentType === "route") { 29 | streetName = component.long_name; 30 | } 31 | else if (componentType === "locality") { 32 | city = component.long_name; 33 | } 34 | 35 | addressCallback(); 36 | }, function() { 37 | callback(null, streetName, streetNumber, city); 38 | }) 39 | 40 | }, true, 'ru'); 41 | } -------------------------------------------------------------------------------- /lib/schedule.js: -------------------------------------------------------------------------------- 1 | var config = require('konfig')(); 2 | 3 | module.exports = { 4 | _getCurrentSchedule: function () { 5 | var schedule = config.app.Schedule; 6 | var currentDate = new Date(); 7 | 8 | // TODO: День недели +1 когда перевалило за полночь и если день > чем 6 то = 0 9 | var dayOfWeek = currentDate.getDay(); 10 | var daySchedule = schedule[dayOfWeek.toString()]; 11 | 12 | return schedule[dayOfWeek.toString()]; 13 | }, 14 | _isOutOfSchedule: function() { 15 | var currentDate = new Date(); 16 | var daySchedule = this._getCurrentSchedule(); 17 | 18 | // Quick hack, convert to Moscow timezone 19 | var hourOfDay = currentDate.getHours() + 4; 20 | if (hourOfDay >= 24) hourOfDay = hourOfDay - 24; 21 | 22 | var range1 = daySchedule.timeRanges[0]; 23 | var range2 = daySchedule.timeRanges[1]; 24 | 25 | var outOfScheduleRequest = false; 26 | if (range1 && range2) { 27 | outOfScheduleRequest = (hourOfDay < range1.start || hourOfDay >= range1.end) && (hourOfDay < range2.start || hourOfDay >= range2.end); 28 | } 29 | else { 30 | outOfScheduleRequest = hourOfDay < range1.start || hourOfDay >= range1.end; 31 | } 32 | 33 | return outOfScheduleRequest; 34 | }, 35 | getSorryMsg: function () { 36 | var message = 'ОГРОМНОЕ спасибо за интерес к Instacab! Все автомобили в настоящее время заполнены, пожалуйста проверьте снова в ближайшее время!'; 37 | if (this._isOutOfSchedule()) { 38 | message = "ОГРОМНОЕ спасибо за интерес к Instacab! Сегодня машины доступны с " + this._getCurrentSchedule().name + ". Пожалуйста попробуйте позже заказать еще раз."; 39 | } 40 | 41 | return message; 42 | }, 43 | getNoneAvailableString: function () { 44 | var message = "НЕТ СВОБОДНЫХ АВТОМОБИЛЕЙ"; 45 | 46 | if (this._isOutOfSchedule()) { 47 | message = "Машины доступны сегодня с " + this._getCurrentSchedule().name; 48 | } 49 | return message; 50 | } 51 | } -------------------------------------------------------------------------------- /log.js: -------------------------------------------------------------------------------- 1 | var logger = require('winston'); 2 | var Loggly = require('winston-loggly').Loggly; 3 | var loggly_options = { subdomain: "node.instacab.ru", inputToken: "efake000-000d-000e-a000-xfakee000a00" } 4 | 5 | logger.add(Loggly, loggly_options); 6 | // logger.add(winston.transports.File, { filename: "logs/production.log" }); 7 | logger.info('Chill Winston, the logs are being captured 2 ways - console and Loggly'); 8 | 9 | module.exports=logger; -------------------------------------------------------------------------------- /messageFactory.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'), 2 | city = require('./models/city'); 3 | 4 | function tripForClientToJSON(trip) { 5 | var vehicle = trip.driver.vehicle; 6 | 7 | // Web Mobile Client 8 | _.extend(vehicle, { 9 | uuid: vehicle.id, 10 | vehicleType: { 11 | make: vehicle.make, 12 | model: vehicle.model 13 | } 14 | }); 15 | 16 | // TODO: exclude if empty 17 | // fareBilledToCard: undefined, 18 | // fare: undefined, 19 | // paidByCard: undefined, 20 | // dropoffAt: undefined, 21 | 22 | return { 23 | id: trip.id, 24 | pickupLocation: { 25 | latitude: trip.pickupLocation.latitude, 26 | longitude: trip.pickupLocation.longitude, 27 | streetAddress: trip.pickupLocation.streetAddress 28 | }, 29 | fareBilledToCard: trip.fareBilledToCard, 30 | fare: trip.fare, 31 | paidByCard: trip.paidByCard, 32 | dropoffAt: trip.dropoffAt, 33 | driver: { 34 | firstName: trip.driver.firstName, 35 | mobile: trip.driver.mobile, 36 | rating: trip.driver.rating, 37 | state: trip.driver.state, 38 | location: trip.driver.location, 39 | photoUrl: trip.driver.picture 40 | }, 41 | vehicle: vehicle, 42 | vehicleViewId: trip.vehicleViewId, 43 | eta: trip.eta 44 | } 45 | } 46 | 47 | function tripToClientMessage(trip, messageType) { 48 | return { 49 | messageType: messageType, 50 | trip: tripForClientToJSON(trip) 51 | } 52 | } 53 | 54 | function userToJSON(user, includeToken) { 55 | var json = { 56 | id: user.id, 57 | firstName: user.firstName, 58 | lastName: user.lastName, 59 | mobile: user.mobile, 60 | rating: user.rating, 61 | state: user.state 62 | }; 63 | 64 | if (includeToken) { 65 | json.token = user.token; 66 | } 67 | 68 | return json; 69 | } 70 | 71 | // TODO: Это должен делать метод User.toJSON 72 | // А для God view сделать отдельный код который будет выбирать нужные данные 73 | function clientToJSON(user, includeToken) { 74 | var json = userToJSON(user, includeToken); 75 | 76 | if (user.paymentProfile) { 77 | json.paymentProfile = user.paymentProfile; 78 | } 79 | 80 | json.hasConfirmedMobile = user.hasConfirmedMobile; 81 | json.referralCode = user.referralCode; 82 | json.isAdmin = user.isAdmin 83 | 84 | return json; 85 | } 86 | 87 | function driverToJSON(driver, includeToken) { 88 | var json = userToJSON(driver, includeToken); 89 | json.vehicle = driver.vehicle; 90 | return json; 91 | } 92 | 93 | function tripForDriverToJSON(trip) { 94 | return { 95 | id: trip.id, 96 | pickupLocation: trip.pickupLocation, 97 | dropoffLocation: trip.dropoffLocation, 98 | dropoffTimestamp: trip.dropoffAt, 99 | fareBilledToCard: trip.fareBilledToCard, 100 | fare: trip.fare, 101 | paidByCard: trip.paidByCard, 102 | client: userToJSON(trip.client) 103 | }; 104 | } 105 | 106 | function GetNoun(number, one, two, five) { 107 | number = Math.abs(number); 108 | number %= 100; 109 | if (number >= 5 && number <= 20) { 110 | return five; 111 | } 112 | number %= 10; 113 | if (number == 1) { 114 | return one; 115 | } 116 | if (number >= 2 && number <= 4) { 117 | return two; 118 | } 119 | return five; 120 | } 121 | 122 | 123 | /////////////////////////////////////////////////////////////////////////////// 124 | // Factory Methods 125 | // 126 | MessageFactory.createClientOK = function(client, options) { 127 | options = options || {}; 128 | 129 | var msg = { 130 | messageType: "OK", 131 | city: city.toJSON() 132 | }; 133 | 134 | if (client) 135 | msg.client = clientToJSON(client, options.includeToken); 136 | 137 | // Trip 138 | if (options.trip) 139 | { 140 | var jsonTrip = tripForClientToJSON(options.trip); 141 | if (options.tripPendingRating) { 142 | msg.client.tripPendingRating = jsonTrip; 143 | } 144 | else 145 | msg.trip = jsonTrip; 146 | } 147 | 148 | var nearbyVehicles = {}; 149 | 150 | // Nearby Vehicles 151 | if (options.vehicles && options.vehicles.length > 0) { 152 | var vehiclePathPoints = options.vehicles; 153 | 154 | // TODO: Преобразовать массив объектов vehiclePathPoints в хэш по ключу viewId в котором есть 155 | // время прибытия ближашего автомобиля из viewId и массив координат [1] по ключам vehiclePathPoint.vehicleId 156 | var vehicleViews = {}, vehicleViewIds; 157 | // convert array to hash by viewId key to get minEta later 158 | vehiclePathPoints.forEach(function(val, i) { 159 | vehicleViews[val.viewId] = vehicleViews[val.viewId] || [] 160 | vehicleViews[val.viewId].push(val); 161 | }); 162 | 163 | vehicleViewIds = Object.keys(vehicleViews); 164 | 165 | // convert vehiclePathPoints to nearby vehiclePaths keyed by vehicle id 166 | vehiclePathPoints.forEach(function(val, i) { 167 | nearbyVehicles[val.viewId] = nearbyVehicles[val.viewId] || { vehiclePaths: {} }; 168 | 169 | // just one path point right now 170 | nearbyVehicles[val.viewId].vehiclePaths[val.id] = [{ 171 | epoch: val.epoch, 172 | latitude: val.latitude, 173 | longitude: val.longitude, 174 | course: val.course 175 | }]; 176 | }); 177 | 178 | // find minEta for each vehicle view 179 | vehicleViewIds.forEach(function(viewId, i) { 180 | var vehicles = vehicleViews[viewId]; 181 | var vehicle = vehicles.length == 1 ? vehicles[0] : _.min(vehicles, function(v){ return v.eta; }); 182 | 183 | var nearbyVehicle = nearbyVehicles[viewId]; 184 | nearbyVehicle.minEta = vehicle.eta; 185 | nearbyVehicle.etaString = vehicle.eta + " " + GetNoun(vehicle.eta, 'минута', 'минуты', 'минут'); 186 | }); 187 | } 188 | 189 | // Sorry that we don't have a car for you 190 | if (options.sorryMsg && options.vehicleViewId) { 191 | nearbyVehicles[options.vehicleViewId] = nearbyVehicles[options.vehicleViewId] || {}; 192 | nearbyVehicles[options.vehicleViewId].sorryMsg = options.sorryMsg; 193 | } 194 | 195 | if (!_.isEmpty(nearbyVehicles)) 196 | msg.nearbyVehicles = nearbyVehicles; 197 | 198 | if (options.apiResponse) { 199 | msg.apiResponse = options.apiResponse; 200 | } 201 | 202 | return msg; 203 | } 204 | 205 | MessageFactory.createClientEndTrip = function(client, trip) { 206 | var msg = { 207 | messageType: "EndTrip", 208 | client: clientToJSON(client), 209 | } 210 | 211 | msg.client.tripPendingRating = tripForClientToJSON(trip); 212 | return msg; 213 | } 214 | 215 | MessageFactory.clientFareEstimate = function(client, estimateLow, estimateHigh, fareEstimateString) { 216 | var msg = { 217 | messageType: "OK", 218 | city: city.toJSON(), 219 | client: clientToJSON(client), 220 | }; 221 | 222 | // Web Mobile Client 223 | msg.client.lastEstimatedTrip = { 224 | fareEstimateLow: estimateLow, 225 | fareEstimateHigh: estimateHigh, 226 | fareEstimateString: fareEstimateString 227 | }; 228 | 229 | return msg; 230 | } 231 | 232 | MessageFactory.createClientPickupCanceled = function(client, reason) { 233 | return { 234 | messageType: 'PickupCanceled', 235 | reason: reason, 236 | client: clientToJSON(client) 237 | } 238 | } 239 | 240 | // TODO: Устрани путаницу с PickupCanceled/TripCanceled, оставить только PickupCanceled и в ответ посылать только OK/Error 241 | MessageFactory.createClientPickupCanceledByDriver = function(client, reason) { 242 | return { 243 | messageType: 'TripCanceled', 244 | reason: reason, 245 | client: clientToJSON(client) 246 | } 247 | } 248 | 249 | MessageFactory.createDriverPickupCanceledByClient = function(driver, reason) { 250 | return { 251 | messageType: 'PickupCanceled', 252 | reason: reason, 253 | driver: clientToJSON(driver) 254 | } 255 | } 256 | 257 | MessageFactory.createError = function(description, errorCode) { 258 | return { 259 | messageType: 'Error', 260 | description: description, 261 | errorText: description, // TODO: Удали когда проверишь Instacab Driver 262 | errorCode: errorCode, // TODO: Удали когда проверишь Instacab Driver 263 | } 264 | } 265 | 266 | // Messages to the Driver 267 | MessageFactory.createDriverOK = function(driver, includeToken, trip, tripPendingRating) { 268 | var msg = { 269 | messageType: "OK", 270 | driver: driverToJSON(driver, includeToken) 271 | } 272 | 273 | if (trip) { 274 | var jsonTrip = tripForDriverToJSON(trip); 275 | if (tripPendingRating) 276 | msg.driver.tripPendingRating = jsonTrip; 277 | else 278 | msg.trip = jsonTrip; 279 | } 280 | 281 | return msg; 282 | }; 283 | 284 | MessageFactory.createDriverVehicleList = function(driver, vehicles) { 285 | return { 286 | messageType: "OK", 287 | driver: driverToJSON(driver), 288 | vehicles: vehicles 289 | } 290 | } 291 | 292 | MessageFactory.createDriverPickup = function(driver, trip, client) { 293 | return { 294 | messageType: 'Pickup', 295 | driver: driverToJSON(driver), 296 | trip: { 297 | id: trip.id, 298 | pickupLocation: trip.pickupLocation, 299 | eta: trip.eta, 300 | client: userToJSON(client) 301 | } 302 | } 303 | } 304 | 305 | function MessageFactory() { 306 | 307 | } 308 | 309 | module.exports = MessageFactory; -------------------------------------------------------------------------------- /models/city.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter, 2 | util = require('util'), 3 | DistanceMatrix = require('../lib/google-distance'), 4 | _ = require('underscore'), 5 | redis = require("redis").createClient(), 6 | mongoClient = require('../mongo_client'), 7 | InNOut = require('in-n-out'); 8 | 9 | function City() { 10 | EventEmitter.call(this); 11 | 12 | this.vehicleViews = {}; 13 | this.sorryMsgGeofence = "К сожалению мы еще не работаем в вашей области. Мы постоянно расширяем наш сервис, следите за обновлениями вступив в группу vk.com/instacab. Напишите нам в Твитере @instacab_vrn"; 14 | 15 | redis.get('city', function(err, reply) { 16 | if (err) throw err; 17 | 18 | if (reply) 19 | this.attributes = this.extractServiceInfo(JSON.parse(reply)); 20 | }.bind(this)); 21 | } 22 | 23 | util.inherits(City, EventEmitter); 24 | 25 | // TODO: DistanceMatrix выдает несколько вариантов маршрута 26 | // https://developers.google.com/maps/documentation/javascript/distancematrix 27 | // Нужно выбрать самый короткий по расстоянию или самый быстрый по времени 28 | 29 | // TODO: Еще лучше сделать вызов RPC метода в Backend и посчитать стоимость там 30 | 31 | City.prototype.estimateFare = function(client, message, callback) { 32 | var m = message, 33 | vehicleView = this.attributes.vehicleViews[m.vehicleViewId], 34 | fare = vehicleView ? vehicleView.fare : null; 35 | 36 | if (!vehicleView || !fare) return callback(new Error('Fare for vehicleViewId ' + m.vehicleViewId + 'not found')); 37 | 38 | DistanceMatrix.get(m.pickupLocation, m.destination, function(err, data) { 39 | if (err) { 40 | // default to 20 minute and 9 km per trip 41 | data = { durationSeconds: 20 * 60, distanceKms: 9 }; 42 | console.log(err); 43 | } 44 | 45 | var distanceKm = data.distanceKms / 1000.0; 46 | 47 | // Time per trip with speed less than 21 km/h = 1.5 min per 5 km 48 | var billedTimeLow = (distanceKm / 5) * 1.5; 49 | // Use 5 minutes per 5 km during traffic 50 | var billedTimeHigh = (distanceKm / 5) * 5; 51 | 52 | // 500 meters for each 5 km below < 21 km/h 53 | var billedDistance; 54 | if (fare.perMinute > 0) 55 | billedDistance = distanceKm - distanceKm * 0.1; 56 | else 57 | billedDistance = distanceKm; 58 | 59 | console.log(" [*] %s + %s * %s", fare.base.toString(), billedDistance.toString(), fare.perKilometer.toString()); 60 | 61 | var estimateLow = Math.round((fare.base + billedTimeLow * fare.perMinute + billedDistance * fare.perKilometer) / 10) * 10; 62 | var estimateHigh = Math.round((fare.base + billedTimeHigh * fare.perMinute + billedDistance * fare.perKilometer) / 10) * 10; 63 | 64 | if (estimateLow < fare.minimum) estimateLow = fare.minimum; 65 | if (estimateHigh < fare.minimum) estimateHigh = fare.minimum; 66 | 67 | var estimateString; 68 | if (estimateLow !== estimateHigh) 69 | estimateString = estimateLow.toString() + '-' + estimateHigh.toString() + ' руб.'; 70 | else 71 | estimateString = estimateLow.toString() + ' руб.'; 72 | 73 | // Log fare quote requests 74 | m.location = [m.longitude, m.latitude]; 75 | m.fareEstimate = { 76 | estimateLow: estimateLow, 77 | estimateHigh: estimateHigh 78 | } 79 | 80 | mongoClient.collection('mobile_events').insert(m, function(err, replies){ 81 | if (err) console.log(err); 82 | }); 83 | 84 | callback(null, require("../messageFactory").clientFareEstimate(client, estimateLow, estimateHigh, estimateString)); 85 | }); 86 | } 87 | 88 | City.prototype.getSorryMsg = function(vehicleViewId) { 89 | return this.vehicleViews[vehicleViewId].sorryMsg; 90 | } 91 | 92 | City.prototype.isPickupLocationAllowed = function(location, vehicleViewId) { 93 | var vehicleView = this.vehicleViews[vehicleViewId]; 94 | if (!vehicleView) return false; 95 | 96 | // instance of InNOut.GeofencedGroup 97 | return vehicleView.geofence.getValidKeys([location.longitude, location.latitude]).length != 0; 98 | } 99 | 100 | City.prototype.update = function(object) { 101 | redis.set('city', JSON.stringify(object), function(err) { 102 | if (err) return console.log(err); 103 | }); 104 | 105 | this.attributes = this.extractServiceInfo(object); 106 | } 107 | 108 | City.prototype.extractServiceInfo = function(attributes) { 109 | for (var vehicleViewId in attributes.vehicleViews) { 110 | var vehicleView = attributes.vehicleViews[vehicleViewId]; 111 | 112 | this.vehicleViews[vehicleViewId] = { 113 | sorryMsg: vehicleView.sorryMsg, 114 | geofence: this.loadGeofence(vehicleView.geofence, vehicleViewId) 115 | }; 116 | 117 | delete vehicleView.sorryMsg; 118 | delete vehicleView.geofence; 119 | } 120 | 121 | return attributes; 122 | } 123 | 124 | City.prototype.loadGeofence = function(geoJSON, vehicleViewId) { 125 | var gfGroup = new InNOut.GeofencedGroup(); 126 | if (!geoJSON) return gfGroup; 127 | 128 | var geofences = []; 129 | geoJSON.features.forEach(function(feature) { 130 | console.log(" [*] Loading geofence '%s' for vehicleViewId %d", feature.properties.name, vehicleViewId); 131 | 132 | geofences.push(new InNOut.Geofence(feature.geometry.coordinates)); 133 | }) 134 | 135 | gfGroup.add(1, geofences, []); 136 | return gfGroup; 137 | } 138 | 139 | City.prototype.isCyclist = function(vehicleViewId) { 140 | if (!vehicleViewId) return false; 141 | 142 | var vehicleView = this.attributes.vehicleViews[vehicleViewId]; 143 | 144 | return vehicleView ? this.attributes.vehicleViews[vehicleViewId].description.toLowerCase() === 'свифт' : false; 145 | } 146 | 147 | City.prototype.toJSON = function() { 148 | return this.attributes; 149 | } 150 | 151 | module.exports = new City(); -------------------------------------------------------------------------------- /models/client.js: -------------------------------------------------------------------------------- 1 | var util = require("util"), 2 | User = require("./user"), 3 | Driver = require("./driver").Driver, 4 | Cache = require('../lib/cache'), 5 | Repository = require('../lib/repository'), 6 | MessageFactory = require("../messageFactory"), 7 | ErrorCodes = require("../error_codes"), 8 | mongoClient = require("../mongo_client"), 9 | city = require('./city'), 10 | config = require('konfig')(); 11 | 12 | function Client() { 13 | User.call(this, Client.LOOKING); 14 | this.isAdmin = false; 15 | } 16 | 17 | util.inherits(Client, User); 18 | 19 | var repository = new Repository(Client); 20 | 21 | /** 22 | * Client States 23 | */ 24 | 25 | ['Looking', 'Dispatching', 'WaitingForPickup', 'OnTrip', 'PendingRating'].forEach(function (readableState, index) { 26 | var state = readableState.toUpperCase(); 27 | Client.prototype[state] = Client[state] = readableState; 28 | }); 29 | 30 | ///////////////////////////////////////////////////// 31 | // Requests 32 | 33 | Client.prototype.login = function(context, callback) { 34 | console.log('Client ' + this.id + ' login, ' + this.state); 35 | this.updateLocation(context); 36 | this.save(); 37 | 38 | this._generateOKResponse(true, callback); 39 | } 40 | 41 | // Return client state and trip if any or available vehicles nearby 42 | Client.prototype.ping = function(context, callback) { 43 | this.updateLocation(context); 44 | 45 | this._generateOKResponse(false, callback); 46 | } 47 | 48 | Client.prototype.pickup = function(context, callback) { 49 | this.updateLocation(context); 50 | 51 | // double request 52 | if (this.state !== Client.LOOKING) return callback(null, this._createOK()); 53 | 54 | // client app will ask user to confirm mobile 55 | if (!this.hasConfirmedMobile) { 56 | require('../backend').requestMobileConfirmation(this.id); 57 | return callback(null, this._createOK()); 58 | } 59 | 60 | var m = context.message; 61 | 62 | // check geofence 63 | if (!city.isPickupLocationAllowed(m.pickupLocation, m.vehicleViewId)) { 64 | require('../backend').clientRequestPickup(this.id, { restrictedLocation: m.pickupLocation }); 65 | 66 | return callback(null, MessageFactory.createClientOK(this, { sorryMsg: city.sorryMsgGeofence, vehicleViewId: m.vehicleViewId })); 67 | } 68 | 69 | // find nearest (by straight line distance from client) driver 70 | Driver.availableSortedByDistanceFrom(m.pickupLocation, m.vehicleViewId, function(err, items){ 71 | if (err) return callback(err); 72 | 73 | if (items.length === 0) { 74 | require('../backend').clientRequestPickup(this.id, { noCarsAvailable: true }); 75 | return callback(null, MessageFactory.createClientOK(this, { sorryMsg: city.getSorryMsg(m.vehicleViewId), vehicleViewId: m.vehicleViewId })); 76 | } 77 | 78 | this._dispatchStillAvailableDriver(context.message.pickupLocation, m.vehicleViewId, items, callback); 79 | }.bind(this)); 80 | } 81 | 82 | // Check again for driver availability, when two pickup requests come at the same time, some client 83 | // may already claimed first driver 84 | Client.prototype._dispatchStillAvailableDriver = function(pickupLocation, vehicleViewId, items, callback) { 85 | require("./trip").Trip.create(function(err, trip) { 86 | var driverFound = items.some(this._dispatchDriver.bind(this, trip, pickupLocation, vehicleViewId)); 87 | 88 | if (driverFound) { 89 | callback(null, this._createOK()); 90 | } 91 | // No drivers 92 | else { 93 | require('../backend').clientRequestPickup(this.id, { noCarsAvailable: true, secondCheck: true }); 94 | 95 | return callback(null, MessageFactory.createClientOK(this, { sorryMsg: city.getSorryMsg(vehicleViewId), vehicleViewId: m.vehicleViewId })); 96 | } 97 | 98 | }.bind(this)); 99 | } 100 | 101 | Client.prototype._dispatchDriver = function(trip, pickupLocation, vehicleViewId, item) { 102 | if (!item.driver.isAvailable()) return false; 103 | 104 | trip.pickup(this, pickupLocation, vehicleViewId, item.driver); 105 | 106 | this.setTrip(trip); 107 | this.changeState(Client.DISPATCHING); 108 | this.save(); 109 | 110 | return true; 111 | } 112 | 113 | // Отменить заказ на поездку 114 | Client.prototype.cancelPickup = function(context, callback) { 115 | this.updateLocation(context); 116 | 117 | if (this.state === Client.DISPATCHING || this.state === Client.WAITINGFORPICKUP) { 118 | this.trip.pickupCanceledClient(); 119 | this.changeState(Client.LOOKING); 120 | this.save(); 121 | } 122 | 123 | this._generateOKResponse(false, callback); 124 | } 125 | 126 | Client.prototype.rateDriver = function(context, callback) { 127 | this.updateLocation(context); 128 | 129 | if (this.state === Client.PENDINGRATING) { 130 | require('../backend').rateDriver(this.trip.id, context.message.rating, context.message.feedback, function() { 131 | this.changeState(Client.LOOKING); 132 | this.save(); 133 | 134 | this._generateOKResponse(false, callback); 135 | }.bind(this)); 136 | } 137 | else 138 | this._generateOKResponse(false, callback); 139 | } 140 | 141 | ///////////////////////////////////////////////////// 142 | // Notifications 143 | 144 | Client.prototype.notifyDriverConfirmed = function() { 145 | if (this.state !== Client.DISPATCHING) return; 146 | 147 | this.changeState(Client.WAITINGFORPICKUP); 148 | this.save(); 149 | 150 | require('../backend').smsTripStatusToClient(this.trip, this); 151 | 152 | this._generateOKResponse(false, function(err, response) { 153 | this.send(response); 154 | }.bind(this)); 155 | } 156 | 157 | // Driver pressed 'Begin Trip' to start trip 158 | Client.prototype.notifyTripStarted = function() { 159 | if (this.state !== Client.WAITINGFORPICKUP) return; 160 | 161 | this.changeState(Client.ONTRIP); 162 | 163 | this._generateOKResponse(false, function(err, response) { 164 | this.send(response); 165 | }.bind(this)); 166 | 167 | this.save(); 168 | } 169 | 170 | Client.prototype.notifyDriverEnroute = function() { 171 | if (this.state === Client.WAITINGFORPICKUP || this.state === Client.ONTRIP) { 172 | this._generateOKResponse(false, function(err, response) { 173 | this.send(response); 174 | }.bind(this)); 175 | } 176 | } 177 | 178 | // Notify client that driver canceled trip 179 | Client.prototype.notifyTripCanceled = function() { 180 | if (this.state !== Client.WAITINGFORPICKUP) return; 181 | 182 | require('../backend').smsTripStatusToClient(this.trip, this); 183 | 184 | // nulls out this.trip 185 | this.changeState(Client.LOOKING); 186 | 187 | this.send(MessageFactory.createClientPickupCanceledByDriver(this, "Водитель был вынужден отменить твой заказ, но возможно у нас есть еще один свободный Instacab! Пожалуйста попробуй снова заказать машину.")); 188 | 189 | this.save(); 190 | } 191 | 192 | Client.prototype.notifyDriverArriving = function() { 193 | if (this.state !== Client.WAITINGFORPICKUP) return; 194 | 195 | this._generateOKResponse(false, function(err, response) { 196 | this.send(response); 197 | }.bind(this)); 198 | 199 | require('../backend').smsTripStatusToClient(this.trip, this); 200 | } 201 | 202 | Client.prototype.notifyTripFinished = function() { 203 | if (this.state !== Client.ONTRIP) return; 204 | 205 | this.changeState(Client.PENDINGRATING); 206 | this.save(); 207 | 208 | this._generateOKResponse(false, function(err, response) { 209 | this.send(response); 210 | }.bind(this)); 211 | } 212 | 213 | // Notify client that pickup request was canceled 214 | Client.prototype.notifyPickupCanceled = function(reason) { 215 | if (this.state !== Client.DISPATCHING) return; 216 | 217 | console.log('Cancel client ' + this.id + ' pickup'); 218 | 219 | this.changeState(Client.LOOKING); 220 | this.save(); 221 | this.send(MessageFactory.createClientPickupCanceled(this, reason)); 222 | } 223 | 224 | Client.prototype.notifyTripBilled = function() { 225 | this.send(this._createOK()); 226 | } 227 | 228 | ////////////////////////////////////////// 229 | // Utility methods 230 | 231 | Client.prototype._createOK = function(includeToken) { 232 | var options = { 233 | includeToken: includeToken || false, 234 | trip: this.trip, 235 | tripPendingRating: this.state === Client.PENDINGRATING 236 | } 237 | 238 | return MessageFactory.createClientOK(this, options); 239 | } 240 | 241 | Client.prototype._generateOKResponse = function(includeToken, callback) { 242 | if (this.state === Client.WAITINGFORPICKUP || this.state === Client.ONTRIP) { 243 | var message = MessageFactory.createClientOK(this, { includeToken: includeToken, trip: this.trip }); 244 | callback(null, message); 245 | } 246 | // Return all vehicles near client location 247 | else if (this.state === Client.LOOKING) { 248 | this.findAndSendDriversNearby({ includeToken: includeToken }, callback); 249 | } 250 | // Return current client state 251 | else { 252 | callback(null, this._createOK(includeToken)) 253 | } 254 | } 255 | 256 | Client.prototype.getSchema = function() { 257 | var props = User.prototype.getSchema.call(this); 258 | props.push('paymentProfile'); 259 | props.push('hasConfirmedMobile'); 260 | props.push('referralCode'); 261 | return props; 262 | } 263 | 264 | Client.prototype.save = function(callback) { 265 | repository.save(this, callback); 266 | } 267 | 268 | Client.prototype.changeState = function(state) { 269 | User.prototype.changeState.call(this, state); 270 | 271 | if (this.state === Client.LOOKING) { 272 | this.clearTrip(); 273 | } 274 | } 275 | 276 | // Notify client about changes in nearby vehicles 277 | Client.prototype.sendDriversNearby = function() { 278 | if (!this.connected || this.state !== Client.LOOKING) return; 279 | 280 | console.log('Update nearby drivers for client ' + this.id + ', connected: ' + this.connected + ', state: ' + this.state); 281 | this.findAndSendDriversNearby({}, function(err, response) { 282 | this.send(response); 283 | }.bind(this)); 284 | } 285 | 286 | Client.prototype.findAndSendDriversNearby = function(options, callback) { 287 | Driver.allAvailableNear(this.location, function(err, vehicles) { 288 | options.vehicles = vehicles; 289 | callback(err, MessageFactory.createClientOK(this, options)); 290 | }.bind(this)); 291 | } 292 | 293 | Client.prototype.toJSON = function() { 294 | var obj = User.prototype.toJSON.call(this); 295 | if (this.trip) { 296 | obj.pickupLocation = this.trip.pickupLocation; 297 | } 298 | return obj; 299 | } 300 | 301 | Client.publishAll = function() { 302 | repository.all(function(err, user) { 303 | user.forEach(function(user) { 304 | user.publish(); 305 | }); 306 | }); 307 | } 308 | 309 | // export Client constructor 310 | module.exports.Client = Client; 311 | module.exports.repository = repository; -------------------------------------------------------------------------------- /models/driver.js: -------------------------------------------------------------------------------- 1 | var MessageFactory = require("../messageFactory"), 2 | LatLon = require('../latlon'), 3 | util = require("util"), 4 | async = require("async"), 5 | Repository = require('../lib/repository'), 6 | DistanceMatrix = require('../lib/google-distance'), 7 | RequestRedisCache = require('request-redis-cache'), 8 | redisClient = require("redis").createClient() 9 | cache = new RequestRedisCache({ redis: redisClient }), 10 | mongoClient = require('../mongo_client'), 11 | city = require('./city'), 12 | User = require("./user"); 13 | 14 | function Driver() { 15 | User.call(this, Driver.OFFDUTY); 16 | this.tripsRejected = this.tripsRejected || 0; 17 | this.tripsAccepted = this.tripsRejected || 0; 18 | } 19 | 20 | util.inherits(Driver, User); 21 | 22 | var repository = new Repository(Driver); 23 | var DEFAULT_PICKUP_TIME_SECONDS = 20 * 60; 24 | 25 | /** 26 | * Driver States 27 | */ 28 | 29 | ['OffDuty', 'Available', 'Reserved', 'Dispatching', 'Accepted', 'Arrived', 'DrivingClient', 'PendingRating'].forEach(function (readableState, index) { 30 | var state = readableState.toUpperCase(); 31 | Driver.prototype[state] = Driver[state] = readableState; 32 | }); 33 | 34 | Driver.prototype.getSchema = function() { 35 | var props = User.prototype.getSchema.call(this); 36 | props.push('vehicle'); 37 | props.push('picture'); 38 | props.push('tripsAccepted'); 39 | props.push('tripsRejected'); 40 | return props; 41 | } 42 | 43 | Driver.prototype.login = function(context, callback) { 44 | // console.log('Driver ' + this.id + ' logged in: ' + this.state + ' connected: ' + this.connected); 45 | 46 | this.updateLocation(context); 47 | if (!this.state) { 48 | this.changeState(Driver.OFFDUTY); 49 | } 50 | 51 | this.buildAndLogEvent('SignInRequest', context); 52 | this.save(); 53 | 54 | return MessageFactory.createDriverOK(this, true, this.trip, false); 55 | } 56 | 57 | Driver.prototype.logout = function(context) { 58 | // console.log('Driver ' + this.id + ' logged out'); 59 | 60 | this.updateLocation(context); 61 | this.buildAndLogEvent('SignOutRequest', context); 62 | 63 | return MessageFactory.createDriverOK(this); 64 | } 65 | 66 | Driver.prototype.onDuty = function(context) { 67 | this.updateLocation(context); 68 | 69 | if (this.state === Driver.OFFDUTY) { 70 | // console.log('Driver ' + this.id + ' on duty'); 71 | this.changeState(Driver.AVAILABLE); 72 | this.buildAndLogEvent('GoOnlineRequest', context); 73 | 74 | this.save(); 75 | } 76 | 77 | return MessageFactory.createDriverOK(this); 78 | } 79 | 80 | Driver.prototype.offDuty = function(context) { 81 | this.updateLocation(context); 82 | 83 | if (this.state === Driver.AVAILABLE) { 84 | // console.log('Driver ' + this.id + ' off duty'); 85 | 86 | this.changeState(Driver.OFFDUTY); 87 | this.buildAndLogEvent('GoOfflineRequest', context); 88 | 89 | this.save(); 90 | } 91 | 92 | return MessageFactory.createDriverOK(this); 93 | } 94 | 95 | // TODO: Записывать изменения позиции водителя в массив последовательных координат 96 | // чтобы позже на клиенте их можно было бы плавно анимировать хоть и не в реальном времени (с небольшой задержкой), 97 | // но за время задержки можно выполнить Map Fitting сгладив индивидуальные точки (устранив погрешности GPS), 98 | // и потом сделать плавную анимацию между точками 99 | // TODO: Записывать если координата действительно отличается, может быть разница всего на 0.00002 тогда она не нужна 100 | 101 | // Update driver's position 102 | Driver.prototype.ping = function(context) { 103 | this.updateLocation(context); 104 | 105 | // Track trip route 106 | if (this.trip) { 107 | this.trip.driverPing(context); 108 | } 109 | 110 | this.logPingEvent(context); 111 | 112 | return MessageFactory.createDriverOK(this, false, this.trip, this.state === Driver.PENDINGRATING); 113 | } 114 | 115 | Driver.prototype.cancelPickup = function(context) { 116 | this.updateLocation(context); 117 | 118 | if (this.state === Driver.DISPATCHING || this.state === Driver.ACCEPTED || this.state === Driver.ARRIVED) { 119 | this.trip.pickupCanceledDriver(context.message.reason); 120 | this.changeState(Driver.AVAILABLE); 121 | this.buildAndLogEvent('PickupCanceledRequest', context); 122 | 123 | this.save(); 124 | } 125 | 126 | return MessageFactory.createDriverOK(this); 127 | } 128 | 129 | Driver.prototype.confirm = function(context) { 130 | this.updateLocation(context); 131 | 132 | if (this.state === Driver.DISPATCHING) { 133 | this.tripsAccepted += 1; 134 | this.changeState(Driver.ACCEPTED); 135 | this.buildAndLogEvent('PickupConfirmedRequest', context); 136 | 137 | this.save(); 138 | } 139 | 140 | return MessageFactory.createDriverOK(this); 141 | } 142 | 143 | Driver.prototype.arriving = function(context) { 144 | this.updateLocation(context); 145 | 146 | if (this.state === Driver.ACCEPTED) { 147 | this.changeState(Driver.ARRIVED); 148 | this.buildAndLogEvent('ArrivingRequest', context); 149 | 150 | this.save(); 151 | } 152 | 153 | return MessageFactory.createDriverOK(this); 154 | } 155 | 156 | Driver.prototype.beginTrip = function(context) { 157 | this.updateLocation(context); 158 | 159 | if (this.state === Driver.ARRIVED) { 160 | this.changeState(Driver.DRIVINGCLIENT); 161 | this.buildAndLogEvent('TripStartedRequest', context); 162 | 163 | this.save(); 164 | } 165 | 166 | return MessageFactory.createDriverOK(this); 167 | } 168 | 169 | Driver.prototype.finishTrip = function(context) { 170 | this.updateLocation(context); 171 | 172 | if (this.state === Driver.DRIVINGCLIENT) { 173 | this.changeState(Driver.PENDINGRATING); 174 | this.buildAndLogEvent('TripFinishedRequest', context); 175 | 176 | this.save(); 177 | } 178 | 179 | return MessageFactory.createDriverOK(this, false, this.trip, this.state === Driver.PENDINGRATING); 180 | } 181 | 182 | Driver.prototype.rateClient = function(context, callback) { 183 | if (this.state !== Driver.PENDINGRATING) return callback(null, MessageFactory.createDriverOK(this)); 184 | 185 | this.updateLocation(context); 186 | 187 | require('../backend').rateClient(this.trip.id, context.message.rating, function() { 188 | this.changeState(Driver.AVAILABLE); 189 | this.save(); 190 | 191 | callback(null, MessageFactory.createDriverOK(this)); 192 | }.bind(this)); 193 | } 194 | 195 | Driver.prototype.listVehicles = function(callback) { 196 | require('../backend').listVehicles(this, function(err, vehicles) { 197 | callback(err, MessageFactory.createDriverVehicleList(this, vehicles)); 198 | }.bind(this)); 199 | } 200 | 201 | Driver.prototype.selectVehicle = function(context, callback) { 202 | require('../backend').selectVehicle(this, context.message.vehicleId, function(err, vehicle) { 203 | if (err) return callback(err); 204 | 205 | this.vehicle = vehicle; 206 | callback(null, MessageFactory.createDriverOK(this)); 207 | }.bind(this)); 208 | } 209 | 210 | Driver.prototype.reserveForDispatch = function() { 211 | if (this.state !== Driver.AVAILABLE) return; 212 | 213 | this.changeState(Driver.RESERVED); 214 | this.save(); 215 | } 216 | 217 | // TODO: Если произошла ошибка посылки Заказа водителю, то перевести водителя в AVAILABLE 218 | // и об этом должен узнать объект Trip 219 | Driver.prototype.dispatch = function(client, trip) { 220 | if (this.state !== Driver.RESERVED) return; 221 | 222 | this.changeState(Driver.DISPATCHING, client); 223 | this.setTrip(trip); 224 | this.save(); 225 | 226 | this.send(MessageFactory.createDriverPickup(this, trip, client)); 227 | } 228 | 229 | // Notify driver that Client canceled pickup or pickup timed out 230 | Driver.prototype.notifyPickupCanceled = function(reason) { 231 | if (Driver.AVAILABLE === this.state) return; 232 | 233 | this.changeState(Driver.AVAILABLE); 234 | this.send(MessageFactory.createDriverPickupCanceledByClient(this, reason)); 235 | this.save(); 236 | } 237 | 238 | Driver.prototype.notifyPickupTimeout = function() { 239 | this.tripsRejected += 1; 240 | this.notifyPickupCanceled(); 241 | } 242 | 243 | Driver.prototype.notifyTripBilled = function() { 244 | // fake driver sends rating without waiting for the fare 245 | if (this.trip) { 246 | this.send(MessageFactory.createDriverOK(this, false, this.trip, true)); 247 | } 248 | } 249 | 250 | Driver.prototype.onDisconnect = function () { 251 | var payload = { 252 | message: { 253 | latitude: this.location.latitude, 254 | longitude: this.location.longitude, 255 | epoch: Math.round(Date.now() / 1000), 256 | deviceId: this.deviceId 257 | } 258 | } 259 | 260 | this.buildAndLogEvent('Disconnect', payload); 261 | } 262 | 263 | Driver.prototype._distanceTo = function(location) { 264 | // FIXME: Оптимизировать позже 265 | return new LatLon(this.location.latitude, this.location.longitude).distanceTo(new LatLon(location.latitude, location.longitude), 4); 266 | } 267 | 268 | Driver.prototype.isDrivingClient = function() { 269 | return this.state === Driver.DRIVINGCLIENT; 270 | } 271 | 272 | Driver.prototype.isAvailable = function() { 273 | // console.log('Driver ' + this.id + ' connected: ' + this.connected + ' state: ' + this.state); 274 | return this.connected && this.state === Driver.AVAILABLE; 275 | } 276 | 277 | function isAvailable(vehicleViewId, driver, callback) { 278 | var result = driver.isAvailable(); 279 | if (vehicleViewId) 280 | result = result && (driver.vehicle.viewId === vehicleViewId); 281 | 282 | callback(result); 283 | } 284 | 285 | function findAvailableDrivers(vehicleViewId, callback) { 286 | repository.filter(isAvailable.bind(null, vehicleViewId), callback.bind(null, null)); // bind function context and first (err) param to null 287 | } 288 | 289 | function round(arg) { 290 | return Math.round(arg * 10000) / 10000; 291 | } 292 | 293 | Driver.prototype._cacheKeyFor = function(pickupLocation) { 294 | return round(this.location.latitude + this.location.longitude + pickupLocation.latitude + pickupLocation.longitude).toString() + '-' + this.vehicle.viewId; 295 | } 296 | 297 | Driver.prototype.queryETAToLocation = function(pickupLocation, callback) { 298 | var self = this; 299 | 300 | console.log(" [*] Query ETA using cache key: %s", this._cacheKeyFor(pickupLocation)) 301 | 302 | // Cache Google Distance Matrix query result, we have only 2500 queries per day 303 | cache.get({ 304 | cacheKey: this._cacheKeyFor(pickupLocation), 305 | cacheTtl: 5 * 60, // 5 minutes in seconds 306 | // Dynamic `options` to pass to our `uncachedGet` call 307 | requestOptions: {}, 308 | // Action to use when we cannot retrieve data from cache 309 | uncachedGet: function (options, cb) { 310 | DistanceMatrix.get(self.location, pickupLocation, function(err, data) { 311 | // Store only approximate driving duration, instead of whole result to save memory 312 | if (!err) { 313 | console.log(" [*] Google Distance Matrix query: %s", Math.ceil(data.durationSeconds / 60)) 314 | 315 | // To get more accurate estimate multiply by 1.2 316 | data = { durationSeconds: data.durationSeconds * 1.2 } 317 | 318 | // FIXME: 319 | if (city.isCyclist(self.vehicle.viewId)) { 320 | data.durationSeconds *= 2; // average driving speed 60 km/h, average cycling speed 20 km/h 321 | } 322 | } 323 | 324 | cb(err, data); 325 | }); 326 | } 327 | }, function handleData (err, data) { 328 | 329 | if (err) { 330 | data = { durationSeconds: DEFAULT_PICKUP_TIME_SECONDS }; 331 | console.log(err); 332 | } 333 | 334 | var eta = Math.ceil(data.durationSeconds / 60); 335 | if (eta === 0) eta = 2; 336 | 337 | console.log(" [*] Final ETA: %s", eta); 338 | 339 | callback(null, eta); 340 | }); 341 | } 342 | 343 | Driver.prototype.toJSON = function() { 344 | var obj = User.prototype.toJSON.call(this); 345 | if (this.trip) { 346 | obj.trip = { 347 | id: this.trip.id, 348 | pickupLocation: this.trip.pickupLocation, 349 | route: this.trip.route 350 | } 351 | } 352 | return obj; 353 | } 354 | 355 | Driver.prototype.save = function() { 356 | repository.save(this); 357 | } 358 | 359 | Driver.prototype.changeState = function(state, client) { 360 | User.prototype.changeState.call(this, state); 361 | 362 | if (this.state === Driver.AVAILABLE) { 363 | this.emit('available', this); 364 | this.clearTrip(); 365 | } 366 | else { 367 | this.emit('unavailable', this, client); 368 | } 369 | } 370 | 371 | function queryDriversETAToLocation(location, drivers, callback) { 372 | async.map(drivers, function(driver, next) { 373 | driver.queryETAToLocation(location, function(err, eta) { 374 | var vehicle = { 375 | id: driver.vehicle.id, 376 | longitude: driver.location.longitude, 377 | latitude: driver.location.latitude, 378 | epoch: driver.location.epoch || 0, 379 | course: driver.location.course || 0, 380 | viewId: driver.vehicle.viewId, 381 | eta: eta 382 | }; 383 | 384 | next(null, vehicle); 385 | }); 386 | }, callback); 387 | } 388 | 389 | Driver.allAvailableNear = function(clientLocation, callback) { 390 | async.waterfall([ 391 | findAvailableDrivers.bind(null, null), 392 | queryDriversETAToLocation.bind(null, clientLocation) 393 | ], callback); 394 | } 395 | 396 | Driver.availableSortedByDistanceFrom = function(pickupLocation, vehicleViewId, callback) { 397 | async.waterfall([ 398 | findAvailableDrivers.bind(null, vehicleViewId), 399 | // find distance to each driver 400 | function(availableDrivers, nextFn) { 401 | console.log("Available drivers:"); 402 | console.log(util.inspect(availableDrivers)); 403 | 404 | async.map( 405 | availableDrivers, 406 | function(driver, cb) { 407 | // distance from client in km 408 | var distanceToClient = driver._distanceTo(pickupLocation); 409 | cb(null, { driver: driver, distanceToClient: distanceToClient }); 410 | }, 411 | nextFn 412 | ); 413 | }, 414 | // order drivers by distance 415 | function(driversAndDistances, nextFn) { 416 | console.log("Available drivers with distance to pickup location:"); 417 | console.log(util.inspect(driversAndDistances)); 418 | 419 | async.sortBy( 420 | driversAndDistances, 421 | function(item, cb) { cb(null, item.distanceToClient) }, 422 | nextFn 423 | ); 424 | } 425 | ], callback); 426 | } 427 | 428 | Driver.publishAll = function() { 429 | repository.all(function(err, drivers) { 430 | drivers.forEach(function(driver) { 431 | driver.publish(); 432 | }); 433 | }); 434 | } 435 | 436 | /////////////////////////////////////////////////////////////////////////////// 437 | /// Log Events 438 | /// 439 | var EPSILON = 0.000002; 440 | 441 | function equalLocations(location1, location2) { 442 | return (Math.abs(location1[0] - location2[0]) <= EPSILON) && (Math.abs(location1[1] - location2[1]) <= EPSILON); 443 | } 444 | 445 | Driver.prototype.logPingEvent = function(context) { 446 | // find last ping location 447 | mongoClient.collection('driver_events').findOne({$query: {eventName: 'PositionUpdateRequest'}, $orderby: { epoch : -1 }}, function(err, lastPing) { 448 | if (err) return console.log(err); 449 | 450 | // log only if location changed to save space 451 | if (!equalLocations(lastPing.location, [context.message.longitude, context.message.latitude])) { 452 | var event = this.buildEvent('PositionUpdateRequest', context); 453 | event.horizontalAccuracy = context.message.horizontalAccuracy; 454 | event.verticalAccuracy = context.message.verticalAccuracy; 455 | 456 | logEvent(event); 457 | } 458 | }.bind(this)); 459 | } 460 | 461 | Driver.prototype.buildEvent = function(eventName, context) { 462 | var payload = context.message; 463 | var event = { 464 | driverId: this.id, 465 | state: this.state, 466 | eventName: eventName, 467 | location: [payload.longitude, payload.latitude], 468 | epoch: payload.epoch, 469 | deviceId: payload.deviceId, 470 | appVersion: payload.appVersion 471 | } 472 | 473 | return event; 474 | } 475 | 476 | Driver.prototype.buildAndLogEvent = function(eventName, context) { 477 | logEvent(this.buildEvent(eventName, context)); 478 | } 479 | 480 | function logEvent(event) { 481 | mongoClient.collection('driver_events').insert(event, function(err, replies){ 482 | if (err) console.log(err); 483 | }); 484 | } 485 | 486 | // export Driver constructor 487 | module.exports.Driver = Driver; 488 | module.exports.repository = repository; -------------------------------------------------------------------------------- /models/trip.js: -------------------------------------------------------------------------------- 1 | var MessageFactory = require("../messageFactory"), 2 | async = require("async"), 3 | util = require('util'), 4 | Uuid = require("uuid-lib"), 5 | Driver = require('./driver').Driver, 6 | apiBackend = require('../backend'), 7 | publisher = require('../publisher'), 8 | ReverseGeocoder = require('../lib/reverse_geocoder'), 9 | Repository = require('../lib/repository'); 10 | 11 | function Trip(id) { 12 | this.rejectedDriverIds = []; 13 | this.route = []; 14 | 15 | if (id) { 16 | this.id = id; 17 | this.createdAt = timestamp(); 18 | this.boundOnDriverDisconnect = this._onDriverDisconnect.bind(this); 19 | } 20 | } 21 | 22 | var PICKUP_TIMEOUT = 15000; // 15 secs 23 | var kFareBillingInProgress = -1; 24 | var repository = new Repository(Trip); 25 | 26 | ['dispatcher_canceled', 'client_canceled', 'driver_confirmed', 'driver_canceled', 'driver_rejected', 'driver_arriving', 'started', 'finished', 'dispatching'].forEach(function (readableState, index) { 27 | var state = readableState.toUpperCase(); 28 | Trip.prototype[state] = Trip[state] = readableState; 29 | }); 30 | 31 | Trip.prototype.load = function(callback) { 32 | var self = this; 33 | async.parallel([ 34 | function(next){ 35 | require("./client").repository.get(self.clientId, function(err, client) { 36 | self.client = client; 37 | next(err); 38 | }) 39 | }, 40 | function(next){ 41 | require("./driver").repository.get(self.driverId, function(err, driver) { 42 | self.driver = driver; 43 | next(err); 44 | }) 45 | }, 46 | ], callback); 47 | } 48 | 49 | Trip.prototype._dispatchToNextAvailableDriver = function() { 50 | console.log('Driver ' + this.driver.id + ' canceled pickup. Finding next one...'); 51 | this._cancelDriverPickup(false); 52 | 53 | console.log("Rejected driver ids:"); 54 | console.log(util.inspect(this.rejectedDriverIds)); 55 | 56 | var self = this; 57 | Driver.availableSortedByDistanceFrom(this.pickupLocation, this.vehicleViewId, function(err, driversWithDistance) { 58 | if (err) { 59 | console.log("Can't find available drivers:"); 60 | console.log(util.inspect(err)); 61 | 62 | return self._cancelClientPickupRequest(); 63 | } 64 | 65 | console.log("Drivers with distance:"); 66 | console.log(util.inspect(driversWithDistance, {depth:1})); 67 | 68 | // Find first driver that hasn't rejected Pickup before 69 | async.detectSeries( 70 | driversWithDistance, 71 | function(item, callback) { 72 | console.log("Check if driver " + item.driver.id + " has not rejected this pickup request..."); 73 | var didNotReject = self.rejectedDriverIds.indexOf(item.driver.id) === -1; 74 | 75 | console.log("... result " + didNotReject); 76 | callback(didNotReject); 77 | }, 78 | function(nextAvailable) { 79 | if (nextAvailable) { 80 | console.log("Next avaiable driver:"); 81 | console.log(util.inspect(nextAvailable, {depth:1})); 82 | 83 | self._setDriver(nextAvailable.driver); 84 | self._dispatchDriver(); 85 | } 86 | else { 87 | self._cancelClientPickupRequest(); 88 | } 89 | 90 | } 91 | ); 92 | 93 | }); 94 | } 95 | 96 | // TODO: Remove from cache once driver and client rated it 97 | // TODO: And keep it in Redis until that 98 | Trip.prototype._archive = function(callback) { 99 | callback = callback || this._defaultCallback; 100 | 101 | apiBackend.addTrip(this, function(err) { 102 | if (err) return callback(err); 103 | 104 | repository.remove(this); 105 | }.bind(this)); 106 | } 107 | 108 | Trip.prototype._save = function(callback) { 109 | repository.save(this, callback); 110 | } 111 | 112 | // IDEA: Возможно соединение потерялось по ошибке, 113 | // и водитель еще успеет восстановить его и отправить подтверждение 114 | Trip.prototype._onDriverDisconnect = function() { 115 | if (this.driver.state === Driver.DISPATCHING) { 116 | this._clearPickupTimeout(); 117 | this._dispatchToNextAvailableDriver(); 118 | } 119 | } 120 | 121 | Trip.prototype._cancelClientPickupRequest = function() { 122 | console.log('No more available drivers to pass Pickup request to'); 123 | 124 | this._changeState(Trip.DISPATCHER_CANCELED); 125 | this._archive(); 126 | this.client.notifyPickupCanceled('Отсутствуют свободные водители. Пожалуйста попробуйте позднее еще раз!'); 127 | } 128 | 129 | Trip.prototype._cancelDriverPickup = function(clientCanceled) { 130 | if (clientCanceled) { 131 | this._clearPickupTimeout(); 132 | this._changeState(Trip.CLIENT_CANCELED); 133 | this.driver.notifyPickupCanceled('Клиент отменил заказ'); 134 | } 135 | else { 136 | this.rejectedDriverIds.push(this.driver.id); 137 | this._changeState(Trip.DRIVER_REJECTED); 138 | this.driver.notifyPickupTimeout(); 139 | } 140 | 141 | this.driver.removeListener('disconnect', this.boundOnDriverDisconnect); 142 | this._save(); 143 | } 144 | 145 | Trip.prototype._clearPickupTimeout = function() { 146 | if (this._pickupTimer) { 147 | clearTimeout(this._pickupTimer); 148 | this._pickupTimer = null; 149 | } 150 | } 151 | 152 | // Two-stage dispatch to prevent driver stealing when multiple clients request pickup 153 | Trip.prototype._dispatchDriver = function() { 154 | this.driver.reserveForDispatch(); 155 | 156 | // In case client app didn't provide us with reverse geocoded address 157 | if (!this.pickupLocation.streetAddress && !this.pickupLocation.city) { 158 | ReverseGeocoder.reverseGeocodeLocation(this.pickupLocation, function(err, streetName, streetNumber, city) { 159 | this.pickupLocation.streetAddress = streetName + ", " + streetNumber; 160 | this.pickupLocation.city = city; 161 | this._save(); 162 | 163 | this._estimateTimeToClientThenDispatch(); 164 | }.bind(this)); 165 | } 166 | else 167 | this._estimateTimeToClientThenDispatch(); 168 | } 169 | 170 | Trip.prototype._estimateTimeToClientThenDispatch = function() { 171 | // Estimate time to client 172 | this.driver.queryETAToLocation(this.pickupLocation, function(err, eta) { 173 | // Keep ETA for client and driver apps 174 | this.eta = eta; 175 | // Give driver 15 seconds to confirm 176 | this._pickupTimer = setTimeout(this._dispatchToNextAvailableDriver.bind(this), PICKUP_TIMEOUT); 177 | this._save(); 178 | 179 | // Send dispatch request 180 | this.driver.dispatch(this.client, this); 181 | 182 | this.publish(); 183 | }.bind(this)); 184 | } 185 | 186 | Trip.prototype.getSchema = function() { 187 | return ['id', 'clientId', 'driverId', 'state', 'cancelReason', 'pickupLocation', 'dropoffLocation', 'confirmLocation', 'pickupAt', 'dropoffAt', 'createdAt', 'fareBilledToCard', 'fare', 'paidByCard', 'rejectedDriverIds', 'route', 'eta', 'secondsToArrival', 'confirmedAt', 'arrivedAt', 'driverRating', 'feedback', 'vehicleViewId']; 188 | } 189 | 190 | Trip.prototype._setClient = function(value) { 191 | this.client = value; 192 | this.clientId = value.id; 193 | } 194 | 195 | Trip.prototype._setDriver = function(driver) { 196 | console.log('Set trip ' + this.id + ' driver to ' + driver.id); 197 | this.driver = driver; 198 | this.driverId = driver.id; 199 | this.driver.once('disconnect', this.boundOnDriverDisconnect); 200 | } 201 | 202 | // Клиент запросил машину 203 | Trip.prototype.pickup = function(client, location, vehicleViewId, driver) { 204 | this._setClient(client); 205 | this._setDriver(driver); 206 | this.pickupLocation = location; 207 | this.vehicleViewId = vehicleViewId; 208 | this._changeState(Trip.DISPATCHING); 209 | this._save(); 210 | 211 | // dispatch to nearest available driver 212 | this._dispatchDriver(); 213 | } 214 | 215 | // Водитель подтвердил заказ. Известить клиента что водитель в пути 216 | Trip.prototype.confirm = function(driverContext, callback) { 217 | var response = this.driver.confirm(driverContext); 218 | 219 | // cleanup 220 | this.driver.removeListener('disconnect', this.boundOnDriverDisconnect); 221 | this.boundOnDriverDisconnect = null; 222 | 223 | if (this.state !== Trip.DRIVER_CONFIRMED) { 224 | this.confirmedAt = timestamp(); 225 | // Keep track for our own ETA engine in the future 226 | this.confirmLocation = this.driver.location; 227 | this._changeState(Trip.DRIVER_CONFIRMED); 228 | this._clearPickupTimeout(); 229 | this._save(); 230 | 231 | this.client.notifyDriverConfirmed(); 232 | } 233 | 234 | callback(null, response); 235 | } 236 | 237 | Trip.prototype._addRouteWayPoint = function(context) { 238 | var payload = context.message; 239 | 240 | var wayPoint = { 241 | latitude: payload.latitude, 242 | longitude: payload.longitude, 243 | horizontalAccuracy: payload.horizontalAccuracy, 244 | verticalAccuracy: payload.verticalAccuracy, 245 | speed: payload.speed, 246 | course: payload.course, 247 | epoch: payload.epoch 248 | }; 249 | 250 | this.route.push(wayPoint); 251 | this._save(); 252 | } 253 | 254 | Trip.prototype.driverPing = function(context) { 255 | if (this.driver.isDrivingClient()) { 256 | this._addRouteWayPoint(context); 257 | } 258 | 259 | this.client.notifyDriverEnroute(); 260 | } 261 | 262 | // Водитель подъезжает. Известить клиента чтобы он выходил 263 | Trip.prototype.driverArriving = function(driverContext, callback) { 264 | var response = this.driver.arriving(driverContext); 265 | 266 | if (this.state === Trip.DRIVER_CONFIRMED) { 267 | this.arrivedAt = timestamp(); 268 | // TODO: Add to schema and to API DB 269 | this.arrivingLocation = this.driver.location; 270 | this.secondsToArrival = this.arrivedAt - this.confirmedAt; 271 | this._changeState(Trip.DRIVER_ARRIVING); 272 | this._save(); 273 | 274 | this.client.notifyDriverArriving(); 275 | } 276 | 277 | callback(null, response); 278 | } 279 | 280 | // Driver canceled trip after confirmation or arrival 281 | Trip.prototype.pickupCanceledDriver = function(cancelReason) { 282 | if (this.state === Trip.DRIVER_CANCELED || this.state === Trip.CLIENT_CANCELED) return; 283 | 284 | this.cancelReason = cancelReason; 285 | this._changeState(Trip.DRIVER_CANCELED); 286 | this._archive(); 287 | 288 | this.client.notifyTripCanceled(); 289 | } 290 | 291 | Trip.prototype.pickupCanceledClient = function() { 292 | if (this.state === Trip.DRIVER_CANCELED || this.state === Trip.CLIENT_CANCELED) return; 293 | 294 | this._changeState(Trip.CLIENT_CANCELED); 295 | this._clearPickupTimeout(); 296 | this._archive(); 297 | 298 | this.driver.notifyPickupCanceled('Клиент отменил заказ'); 299 | } 300 | 301 | // Водитель начал поездку. Известить клиента что поездка началась 302 | Trip.prototype.driverBegin = function(driverContext, callback) { 303 | var response = this.driver.beginTrip(driverContext); 304 | 305 | if (this.state === Trip.DRIVER_ARRIVING) { 306 | this.pickupAt = timestamp(); 307 | // Use as a starting point for the trip, because actual begin trip position could be different 308 | // from stated pickup position: traffic jams, one way street 309 | this._addRouteWayPoint(driverContext); 310 | this._changeState(Trip.STARTED); 311 | this._save(); 312 | 313 | this.client.notifyTripStarted(); 314 | } 315 | 316 | callback(null, response); 317 | } 318 | 319 | // Водитель завершил поездку. Известить клиента что поездка была завершена 320 | Trip.prototype.driverEnd = function(context, callback) { 321 | if (this.state === Trip.STARTED) { 322 | this.dropoffAt = timestamp(); 323 | this.fareBilledToCard = kFareBillingInProgress; 324 | this.fare = kFareBillingInProgress; 325 | this.dropoffLocation = { 326 | latitude: context.message.latitude, 327 | longitude: context.message.longitude 328 | }; 329 | 330 | this._addRouteWayPoint(context); 331 | this._changeState(Trip.FINISHED); 332 | 333 | ReverseGeocoder.reverseGeocodeLocation(this.dropoffLocation, function(err, streetName, streetNumber, city) { 334 | this.dropoffLocation.streetAddress = streetName + ", " + streetNumber; 335 | this.dropoffLocation.city = city; 336 | 337 | this.publish(); 338 | this._save(); 339 | 340 | this._bill(); 341 | }.bind(this)); 342 | 343 | this._save(); 344 | 345 | this.client.notifyTripFinished(); 346 | } 347 | 348 | callback(null, this.driver.finishTrip(context)); 349 | } 350 | 351 | Trip.prototype._bill = function() { 352 | apiBackend.billTrip(this, function(err, fareBilledToCard, fare, paidByCard) { 353 | if (err) console.log(err); 354 | 355 | console.log('Trip ' + this.id + ' billed fare is ' + fareBilledToCard + ' руб.'); 356 | console.log('Trip ' + this.id + ' total fare is ' + fare + ' руб.'); 357 | this.fareBilledToCard = fareBilledToCard; 358 | this.fare = fare; 359 | this.paidByCard = paidByCard; 360 | this.publish(); 361 | this._save(); 362 | 363 | this.client.notifyTripBilled(); 364 | this.driver.notifyTripBilled(); 365 | 366 | }.bind(this)); 367 | } 368 | 369 | Trip.prototype.clientRateDriver = function(context, callback) { 370 | if (this.state === Trip.FINISHED && !this.driverRating) { 371 | this.driverRating = context.message.rating; 372 | this.feedback = context.message.feedback; 373 | 374 | // TODO: Push trip to Backend if driver rated 375 | // Даже лучше пусть заведен таймер и раз в 15 минут все поездки с оценками перемещаются в архив 376 | // Чтобы поездки не исчезали когда за ними наблюдает кто то. 377 | // Таким образом можно оставлять поездки которые были с ошибками, посылать email & sms Alert 378 | // чтобы человек пришел и нашел эти поездки в Диспетчере 379 | this._save(); 380 | } 381 | 382 | this.client.rateDriver(context, callback); 383 | } 384 | 385 | // At this point driver goes back on duty 386 | Trip.prototype.driverRateClient = function(context, callback) { 387 | if (this.state === Trip.FINISHED && !this.clientRating) { 388 | this.clientRating = context.message.rating; 389 | 390 | // TODO: Push trip to Backend if client rated 391 | // Даже лучше пусть заведен таймер и раз в 15 минут все поездки с оценками перемещаются в архив 392 | this._save(); 393 | } 394 | 395 | this.driver.rateClient(context, callback); 396 | } 397 | 398 | Trip.prototype._changeState = function(state) { 399 | if (this.state !== state) { 400 | this.state = state; 401 | this.publish(); 402 | } 403 | } 404 | 405 | Trip.prototype._defaultCallback = function(err) { 406 | if (err) console.log(err); 407 | } 408 | 409 | Trip.prototype.publish = function() { 410 | publisher.publish('channel:trips', JSON.stringify(this)); 411 | } 412 | 413 | Trip.prototype.toJSON = function() { 414 | return { 415 | id: this.id, 416 | client: this.client, 417 | driver: this.driver, 418 | state: this.state, 419 | route: this.route, 420 | pickupLocation: this.pickupLocation, 421 | dropoffLocation: this.dropoffLocation, 422 | fare: this.fare, 423 | fareBilledToCard: this.fareBilledToCard, 424 | eta: this.eta, 425 | createdAt: this.createdAt, 426 | pickupAt: this.pickupAt, 427 | dropoffAt: this.dropoffAt 428 | }; 429 | } 430 | 431 | Trip.create = function(callback) { 432 | repository.generateNextId(function(err, id){ 433 | callback(err, new Trip(id)); 434 | }); 435 | } 436 | 437 | Trip.publishAll = function() { 438 | repository.all(function(err, trips) { 439 | publisher.publish('channel:trips', JSON.stringify({trips: trips})); 440 | }); 441 | } 442 | 443 | function timestamp() { 444 | return Math.round(Date.now() / 1000); 445 | } 446 | 447 | // export Trip constructor 448 | module.exports.Trip = Trip; 449 | module.exports.repository = repository; 450 | 451 | -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | var WebSocket = require('ws'), 2 | util = require('util'), 3 | EventEmitter = require('events').EventEmitter, 4 | assert = require('assert'), 5 | publisher = require('../publisher'); 6 | 7 | // Create a new object, that prototypally inherits from the Error constructor 8 | function NetworkError(message, socketError) { 9 | this.name = "NetworkError"; 10 | this.message = message || "Default Message"; 11 | this.socketError = socketError; 12 | } 13 | 14 | NetworkError.prototype = new Error(); 15 | NetworkError.prototype.constructor = NetworkError; 16 | 17 | /** User 18 | * 19 | */ 20 | 21 | function User(defaultState) { 22 | EventEmitter.call(this); 23 | 24 | this.connected = false; 25 | this.state = defaultState; 26 | this.channelName = 'channel:' + this.constructor.name.toLowerCase() + 's'; 27 | 28 | this._onConnectionClosed = this._connectionClosed.bind(this); 29 | this._onConnectionError = this._connectionError.bind(this); 30 | } 31 | 32 | util.inherits(User, EventEmitter); 33 | 34 | User.prototype.getSchema = function() { 35 | return ['id', 'firstName', 'lastName', 'email', 'token', 'deviceId', 'mobile', 'rating', 'state', 'location', 'tripId', 'isAdmin']; 36 | } 37 | 38 | User.prototype.load = function(callback) { 39 | if (this.tripId) { 40 | require('./trip').repository.get(this.tripId, function(err, trip){ 41 | this.trip = trip; 42 | callback(err); 43 | }.bind(this)); 44 | } 45 | else 46 | callback(); 47 | } 48 | 49 | User.prototype.setTrip = function(trip) { 50 | this.trip = trip; 51 | this.tripId = trip.id; 52 | } 53 | 54 | User.prototype.clearTrip = function() { 55 | this.trip = null; 56 | this.tripId = null; 57 | } 58 | 59 | User.prototype.send = function(message) { 60 | if (!this.connection || this.connection.readyState !== WebSocket.OPEN) { 61 | console.log(this.constructor.name + ' ' + this.id + ' is not connected'); 62 | return; 63 | } 64 | 65 | console.log('Sending ' + message.messageType + ' to ' + this.constructor.name + ' ' + this.id); 66 | console.log(util.inspect(message, {depth: 3})); 67 | 68 | this.connection.send(JSON.stringify(message)); 69 | } 70 | 71 | User.prototype.disconnect = function() { 72 | if (this.connection && this.connection.readyState === WebSocket.OPEN) { 73 | this.connection.close(); 74 | } 75 | 76 | this.connection = null; 77 | } 78 | 79 | User.prototype._connectionClosed = function() { 80 | console.log(this.constructor.name + ' ' + this.id + ' disconnected'); 81 | 82 | this.connected = false; 83 | // cleanup 84 | if (this.connection) { 85 | this.connection.removeListener('error', this._onConnectionError); 86 | this.connection.removeListener('close', this._onConnectionClosed); 87 | } 88 | this.connection = null; 89 | 90 | this.onDisconnect(); 91 | 92 | this.emit('disconnect', this); 93 | this.publish(); 94 | } 95 | 96 | User.prototype.onDisconnect = function () {} 97 | 98 | User.prototype.publish = function() { 99 | publisher.publish(this.channelName, JSON.stringify(this)); 100 | } 101 | 102 | User.prototype._connectionError = function() { 103 | console.log(this.constructor.name + ' ' + this.id + ' connection error'); 104 | } 105 | 106 | function isEqualLocations(oldLocation, newLocation) { 107 | return oldLocation.latitude === newLocation.latitude && 108 | oldLocation.longitude === newLocation.longitude; 109 | } 110 | 111 | User.prototype._setConnection = function(connection, deviceId) { 112 | var isNewConnection = this.connection !== connection; 113 | if (!isNewConnection) return; 114 | 115 | console.log(this.constructor.name + ' ' + this.id + ' connected'); 116 | 117 | // use device id to prevent double login 118 | this.deviceId = deviceId; 119 | this.connected = connection.readyState === WebSocket.OPEN; 120 | 121 | // subscribe to connection events 122 | connection.once('close', this._onConnectionClosed); 123 | connection.once('error', this._onConnectionError); 124 | 125 | // keep connection to send messages later 126 | if (this.connection) delete this.connection; 127 | this.connection = connection; 128 | 129 | this.emit('connect', this); 130 | this.publish(); 131 | } 132 | 133 | User.prototype.isTokenValid = function(message) { 134 | return message.token && this.token && message.token === this.token; 135 | } 136 | 137 | User.prototype.updateLocation = function(context) { 138 | var newLocation = { 139 | epoch: context.message.epoch, 140 | latitude: context.message.latitude, 141 | longitude: context.message.longitude, 142 | course: context.message.course 143 | }; 144 | 145 | var locationChanged = !this.location || !isEqualLocations(this.location, newLocation); 146 | this.location = newLocation; 147 | 148 | // Notify observers when location changed 149 | if (locationChanged) { 150 | this.emit('locationUpdate', this, newLocation); 151 | this.publish(); 152 | } 153 | 154 | this._setConnection(context.connection, context.message.deviceId); 155 | } 156 | 157 | User.prototype.changeState = function(state) { 158 | assert(state, 'Can not change state to ' + state); 159 | console.log('Change ' + this.constructor.name + ' ' + this.id + ' state from ' + this.state + ' to ' + state); 160 | 161 | var oldState = this.state; 162 | this.state = state; 163 | 164 | if (oldState !== state) { 165 | this.publish(); 166 | } 167 | }; 168 | 169 | User.prototype.update = function(userInfo) { 170 | Object.keys(userInfo).forEach(function(propName) { 171 | this[propName] = userInfo[propName]; 172 | }.bind(this)); 173 | 174 | this.save(); 175 | } 176 | 177 | User.prototype.toJSON = function() { 178 | return { 179 | id: this.id, 180 | name: this.firstName, 181 | location: this.location, 182 | state: this.state, 183 | connected: this.connected, 184 | }; 185 | } 186 | 187 | module.exports = User; -------------------------------------------------------------------------------- /mongo_client.js: -------------------------------------------------------------------------------- 1 | var mongo = require('mongoskin'); 2 | 3 | module.exports = mongo.db("mongodb://localhost:27017/instacab", {native_parser:true}); 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "instacab-dispatch", 3 | "version": "0.0.1", 4 | "dependencies": 5 | { 6 | "express": "3.x", 7 | "async": "~> 0.9.0", 8 | "redis": "~> 0.10.3", 9 | "uuid-lib": "0.0.6", 10 | "underscore": "1.5.0", 11 | "ws": "0.4.31", 12 | "request": "2.29", 13 | "googlemaps" :"0.1.9", 14 | "konfig": "~> 0.1.1", 15 | "js-yaml": "~> 3.0.0", 16 | "bugsnag": "~> 1.1.0", 17 | "nodetime": "~> 0.8.15", 18 | "in-n-out": "~> 0.0.10", 19 | "faye-websocket": "~>0.7.2", 20 | "mongoskin": "~>1.3.20", 21 | "winston": "~>0.7.2", 22 | "cors": "2.2.0", 23 | "request-redis-cache": "~> 0.1.0", 24 | "amqplib": "~> 0.2.0" 25 | }, 26 | "engines": { 27 | "node": "0.10.x", 28 | "npm": "1.3.x" 29 | } 30 | } -------------------------------------------------------------------------------- /publisher.js: -------------------------------------------------------------------------------- 1 | module.exports = require("redis").createClient(); --------------------------------------------------------------------------------