├── .babelrc ├── .dockerignore ├── .ebignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── .travis.yml ├── CREDITS.md ├── Dockerfile ├── Dockerrun.aws.json ├── LICENSE ├── Procfile ├── README.md ├── data ├── beelineLogo.png ├── busplusLogo.png ├── companyMap.json ├── iconpax.png ├── ph.ics ├── statement.html ├── suggestion-verification.html ├── suggestion-verification.txt ├── wrs-textonly.txt ├── wrs.html └── wrs.txt ├── newrelic.js ├── package-lock.json ├── package.json ├── run-branch-tests.sh ├── scripts ├── charge-id-csv.js ├── db_init.js ├── login-as.js ├── makedumps.sh ├── post_dump.sql ├── prettier-lint.sh ├── refund-id-csv.js ├── send-trip-to.js ├── setup_virtualenv.sh ├── trial-AM.js ├── trial.js └── update_sequences.js ├── src ├── index.js └── lib │ ├── aws │ ├── expireStaleRoutePasses.js │ └── smoketest.js │ ├── core │ ├── auth.js │ ├── dbschema.js │ ├── download.js │ ├── modelCache.js │ ├── sequelize.js │ └── version.js │ ├── custom │ ├── userSuggestedRoutes.js │ └── wrs.js │ ├── daemons │ ├── eventSubscriptions.js │ └── scheduler.js │ ├── endpoints │ ├── admins.js │ ├── assets.js │ ├── companies.js │ ├── companyContactLists.js │ ├── companyPromos.js │ ├── crowdstart.js │ ├── drivers.js │ ├── eventSubscriptions.js │ ├── liteRoutes.js │ ├── onemap.js │ ├── routePassAdmin.js │ ├── routes.js │ ├── stops.js │ ├── suggestedRoutes.js │ ├── suggestions.js │ ├── suggestionsWeb.js │ ├── tickets.js │ ├── transactionItems.js │ ├── transactions.js │ ├── tripStatuses.js │ ├── trips.js │ ├── userPaymentInfo.js │ ├── users.js │ └── vehicles.js │ ├── events │ ├── definitions.js │ ├── events.js │ ├── formatters.js │ └── handlers.js │ ├── listings │ └── routes.js │ ├── models │ ├── Account.js │ ├── Admin.js │ ├── AdminCompany.js │ ├── Alert.js │ ├── Asset.js │ ├── Bid.js │ ├── ContactList.js │ ├── Discount.js │ ├── Driver.js │ ├── DriverCompany.js │ ├── EventSubscription.js │ ├── IndicativeTrip.js │ ├── Payment.js │ ├── PromoUsage.js │ ├── Promotion.js │ ├── RefundPayment.js │ ├── Route.js │ ├── RouteAnnouncement.js │ ├── RouteCredit.js │ ├── RoutePass.js │ ├── Stop.js │ ├── Subscription.js │ ├── SuggestedRoute.js │ ├── Suggestion.js │ ├── Ticket.js │ ├── Transaction.js │ ├── TransactionItem.js │ ├── Transfer.js │ ├── TransportCompany.js │ ├── Trip.js │ ├── TripStop.js │ ├── User.js │ ├── UserSuggestedRoute.js │ ├── UserSuggestedRouteStop.js │ └── Vehicle.js │ ├── promotions │ ├── Promotion.js │ ├── RoutePass.js │ ├── functions │ │ ├── discountRefunds.js │ │ ├── routePassDiscountQualifiers.js │ │ └── ticketDiscountQualifiers.js │ └── index.js │ ├── routes.js │ ├── transactions │ ├── builder.js │ ├── index.js │ ├── payment.js │ └── routePass.js │ └── util │ ├── analytics.js │ ├── common.js │ ├── email.js │ ├── endpoints.js │ ├── errors.js │ ├── image.js │ ├── joi.js │ ├── onesignal.js │ ├── sms.js │ ├── svy21.js │ └── telegram.js └── test ├── admins.js ├── assets.js ├── auth.js ├── common.js ├── companies.js ├── companyContactLists.js ├── companyPromos.js ├── crowdstart.js ├── discountedRoutePassPurchases.js ├── discountedTransactions.js ├── discounts.js ├── drivers.js ├── eventSubscriptions.js ├── expireStaleRoutePasses.js ├── joi.js ├── liteRoutes.js ├── onemap.js ├── ph.ics ├── privacy.js ├── promoUsage.js ├── promotionCalculations.js ├── routePass.js ├── routePassAdmin.js ├── routePassDiscountQualifiers.js ├── routePricing.js ├── routes.js ├── stops.js ├── stripe.js ├── suggestedRoutes.js ├── suggestions.js ├── suggestionsWeb.js ├── telegram.js ├── testToInvestigate.js ├── test_common.js ├── test_data.js ├── ticketDiscountQualifiers.js ├── tickets.js ├── timemachine-wrap.js ├── transactionDeadlocks.js ├── transactionHistory.js ├── transactionItems.js ├── transactions.js ├── transactionsBookingWindow.js ├── tripAvailability.js ├── tripStatuses.js ├── trips.js ├── uploadLogo.html ├── uploadLogoForbidden.html ├── uploadPhoto.html ├── uploadPhotoForbidden.html ├── userPaymentInfo.js ├── userSuggestedRoutes.js ├── users.js ├── vehicles.js ├── wrs.js └── zeroDollarTransactions.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-runtime", 4 | "transform-es2015-modules-commonjs", 5 | "syntax-object-rest-spread" 6 | ], 7 | "sourceMaps": "inline" 8 | } 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.ebignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "parser": "babel-eslint", 7 | "plugins": [ 8 | "babel" 9 | ], 10 | "extends": [ 11 | "eslint:recommended", 12 | "google", 13 | "prettier" 14 | ], 15 | "rules": { 16 | "no-unused-vars": ["error"], 17 | "semi": ["error", "never"], 18 | "comma-dangle": ["error", "always-multiline"], 19 | "radix": ["error", "as-needed"], 20 | "require-jsdoc": ["warn"], 21 | "no-console": ["error", {"allow": ["warn", "error"]}], 22 | "one-var": ["off"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | npm-debug.log 4 | .DS_Store 5 | # Ignore files that are now generated by Typescript 6 | **/*.map 7 | dist/** 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5" 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Copy in package.json into the image and install node modules 6 | # These layers are only rebuilt if package.json changes 7 | COPY package.json . 8 | 9 | # Install tzdata so that we can easily get the local datetime 10 | RUN apk update && apk add tzdata 11 | 12 | RUN npm install 13 | 14 | # Copy rest of source code into image 15 | COPY data/ data/ 16 | COPY src/ src/ 17 | COPY .babelrc . 18 | 19 | RUN npm run build && \ 20 | npm prune --production 21 | 22 | RUN rm -rf src 23 | 24 | RUN mkdir logs 25 | 26 | EXPOSE 10000 27 | ENV PORT 10000 28 | 29 | CMD node dist/index.js 30 | -------------------------------------------------------------------------------- /Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSEBDockerrunVersion": "1", 3 | "Image": { 4 | "Name": "datagovsg/beeline-server:@TAG", 5 | "Update": "true" 6 | }, 7 | "Ports": [ 8 | { 9 | "ContainerPort": "10000" 10 | } 11 | ], 12 | "Logging": "/app/logs" 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Government Technology Agency of Singapore 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node dist/index.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | How to start using the server 2 | ============= 3 | 4 | Install dependencies 5 | ---------- 6 | Go to the root directory of this project and run 7 | 8 | $ npm install 9 | 10 | In addition, you will find it useful to install the following globally: 11 | 12 | This gives you `babel-node` which you need to run scripts (e.g. `db_init`, 13 | `login-as`): 14 | 15 | $ npm install -g babel-cli 16 | 17 | Edit config file 18 | ---------- 19 | 20 | Edit the config file at `config.js` 21 | 22 | 1. `tls: false` to disable HTTPS while testing 23 | 2. SSL Certificates 24 | 25 | Create the postgres database 26 | ---------- 27 | 28 | (Not necessary for running tests -- the test database is created automatically) 29 | To create a working postgres database, call: 30 | 31 | $ export DATABASE_URL=postgres://user:password@localhost/database 32 | $ babel-node scripts/db_init 33 | 34 | Run server 35 | ---------- 36 | 37 | $ npm start 38 | 39 | Run tests 40 | ---------- 41 | 42 | Run the tests on a database initialized with `db_init` 43 | 44 | $ npm run test 45 | 46 | OR: Run tests on a database dump previously downloaded 47 | 48 | $ npm run test_cached 49 | 50 | OR: Run tests on a database dump downloaded from `staging.beeline.sg` 51 | 52 | $ npm run test_current 53 | 54 | The test script is `setup_virtualenv.sh`. 55 | 56 | Environment variables that affect tests: 57 | 58 | TEST_STRIPE=1 -- Executes stripe charges (using fake credit card number 4242 4242 4242 4242) 59 | LAB_OPTIONS -- Specifies options to lab, the test framework (try `--inspect`) 60 | 61 | Useful scripts 62 | -------------- 63 | 64 | Generate session token as a driver, admin, user or superadmin. 65 | 66 | Example: 67 | 68 | $ babel-node scripts/login-as.js superadmin 69 | $ babel-node scripts/login-as.js driver 1 70 | 71 | Contributing 72 | ============ 73 | We welcome contributions to code open sourced by the Government Technology Agency of Singapore. All contributors will be asked to sign a Contributor License Agreement (CLA) in order to ensure that everybody is free to use their contributions. 74 | -------------------------------------------------------------------------------- /data/beelineLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datagovsg/beeline-server/56ca9dc0e4bfff173009ddd18f2ee8833def1b44/data/beelineLogo.png -------------------------------------------------------------------------------- /data/busplusLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datagovsg/beeline-server/56ca9dc0e4bfff173009ddd18f2ee8833def1b44/data/busplusLogo.png -------------------------------------------------------------------------------- /data/companyMap.json: -------------------------------------------------------------------------------- 1 | {"0":3,"1":4,"2":6,"3":5,"4":7,"5":9,"6":8,"7":10} -------------------------------------------------------------------------------- /data/iconpax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datagovsg/beeline-server/56ca9dc0e4bfff173009ddd18f2ee8833def1b44/data/iconpax.png -------------------------------------------------------------------------------- /data/statement.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 12 |

Transaction Statement

13 |

{{formatDate query.startDate}} -- {{formatDate query.endDate}}

14 | 15 | 20 | 21 |

Tickets Sold

22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {{#each data.ticketSale}} 35 | 36 | 39 | 42 | 46 | 49 | 50 | {{/each}} 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
DateTicket IDRouteCredit
37 | {{formatDate createdAt}} 38 | 40 | {{ticketSale.id}} 41 | 43 | ({{ticketSale.boardStop.trip.route.label}}) 44 | {{ticketSale.boardStop.trip.route.name}} 45 | 47 | {{credit}} 48 |
Total{{sumCredit data.ticketSale}}
61 | 62 |

Tickets Refunded

63 | {{sumCredit data.ticketRefund}} 64 | 65 |

Tickets Refunded

66 | {{sumCredit data.ticketRefund}} 67 | 68 | 69 |

Payments Received

70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | {{#each data.payment}} 82 | 83 | 86 | 89 | 92 | 93 | {{/each}} 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
DateCharge IDCredit
84 | {{formatDate createdAt}} 85 | 87 | {{payment.paymentResource}} 88 | 90 | {{debit}} 91 |
Total{{sumCredit data.ticketSale}}
104 | 105 |

Refunds Paid Out

106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | {{#each data.refundPayment}} 118 | 119 | 122 | 125 | 128 | 129 | {{/each}} 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |
DateRouteCredit
120 | {{createdAt}} 121 | 123 | {{refundPayment.paymentResource}} 124 | 126 | {{debit}} 127 |
Total{{sumCredit data.refundPayment}}
140 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /data/suggestion-verification.html: -------------------------------------------------------------------------------- 1 |

Dear User,

2 | 3 |

Thank you for submitting a suggestion to Beeline! Please verify your email address by clicking on the link below:

4 | 5 |

{{verificationLink}}

6 | 7 |

If you did not make a suggestion to Beeline, please ignore this email.

8 | 9 |

Cheers,
10 | The Beeline Team

11 | -------------------------------------------------------------------------------- /data/suggestion-verification.txt: -------------------------------------------------------------------------------- 1 | Dear User, 2 | 3 | Thank you for submitting a suggestion to Beeline! Please verify your email address by clicking on the link below: 4 | 5 | {{{verificationLink}}} 6 | 7 | If you did not make a suggestion to Beeline, please ignore this email. 8 | 9 | Cheers, 10 | The Beeline Team 11 | -------------------------------------------------------------------------------- /data/wrs-textonly.txt: -------------------------------------------------------------------------------- 1 | Bus Rides to Singapore Zoo 2 | ========================== 3 | 4 | Please print this receipt and show it to the driver. 5 | 6 | Booking Receipt 7 | --------------- 8 | 9 | No. of Adults: {{{adultPax}}} 10 | No. of Children: {{{childPax}}} 11 | Trip Date: {{{formatDateLong date}}} 12 | 13 | Trips Booked 14 | ------------ 15 | 16 | {{#each trips}} 17 | {{{time}}} - {{{description}}} 18 | {{/each}} 19 | 20 | Booking Details 21 | --------------- 22 | 23 | Name: {{{user.name}}} 24 | Booking ID: {{{formatBookingId bookingId}}} 25 | Amount Paid: {{{amountPaid}}} 26 | 27 | {{#if toZoo}} 28 | Getting to the Zoo 29 | ------------------ 30 | 31 | Pick-Up at: {{{formatTime toZoo.boardStop.time}}} 32 | {{{formatStop toZoo.boardStop.stop}}} 33 | {{{toZoo.boardStop.stop.road}}} 34 | 35 | {{#if toZoo.boardStop.stop.viewUrl}} 36 | {{{toZoo.boardStop.stop.viewUrl}}} 37 | {{/if}} 38 | 39 | 40 | Drop-off at: {{{formatTime toZoo.alightStop.time}}} 41 | {{{formatStop toZoo.alightStop.stop}}} 42 | {{{toZoo.alightStop.stop.road}}} 43 | 44 | {{#if toZoo.alightStop.stop.viewUrl}} 45 | {{{toZoo.alightStop.stop.viewUrl}}} 46 | {{/if}} 47 | 48 | {{/if}} 49 | 50 | {{#if fromZoo}} 51 | Return Trip from the Zoo 52 | ------------------------ 53 | 54 | Pick-Up at: {{{formatTime fromZoo.boardStop.time}}} 55 | {{{formatStop fromZoo.boardStop.stop}}} 56 | {{{fromZoo.boardStop.stop.road}}} 57 | 58 | {{#if fromZoo.boardStop.stop.viewUrl}} 59 | {{{fromZoo.boardStop.stop.viewUrl}}} 60 | {{/if}} 61 | 62 | 63 | Drop-off at: {{{formatTime fromZoo.alightStop.time}}} 64 | {{{formatStop fromZoo.alightStop.stop}}} 65 | {{{fromZoo.alightStop.stop.road}}} 66 | 67 | {{#if fromZoo.alightStop.stop.viewUrl}} 68 | {{{fromZoo.alightStop.stop.viewUrl}}} 69 | {{/if}} 70 | 71 | {{/if}} 72 | 73 | Important Notes 74 | --------------- 75 | For enquiry, please contact : 76 | Tel: (+65) 6269 3411 77 | Email: enquiry@wrs.com.sg 78 | 79 | Booking Policy 80 | --------------- 81 | * Buses are operated by Wildlife Reserves Singapore Group 82 | * Please be at your pick-up stop 10 mins before stipulated time. 83 | * Children below 3 years old ride for free and can board with an accompanying passenger with a valid booking receipt. 84 | * You will not be able to board shuttle services at other timings as bus seats are pre-booked and capacity is limited. 85 | * Strictly no cancellation. 86 | * Look out for the printed signage in front of the bus when you board. 87 | 88 | Read More At: https://mandaiexpress.beeline.sg/booking_policy.html 89 | 90 | ------------------------------------------------------------------------------- 91 | This booking service is powered by Beeline.sg 92 | 93 | Terms of Use: https://mandaiexpress.beeline.sg/terms_of_use.html 94 | Privacy Policy: https://mandaiexpress.beeline.sg/privacy_policy.html 95 | -------------------------------------------------------------------------------- /data/wrs.txt: -------------------------------------------------------------------------------- 1 | From: Beeline 2 | To: {{{customerEmail}}} 3 | Subject: Your Beeline Ticket (Booking #{{{bookingId}}}) 4 | MIME-Version: 1.0 5 | Content-Type: multipart/related; boundary="beesbeesbeesbeesbeesbees" 6 | Content-Transfer-Encoding: 8bit 7 | 8 | --beesbeesbeesbeesbeesbees 9 | Content-Type: multipart/alternative; boundary="buzzbuzzbuzzbuzzbuzzbuzz" 10 | MIME-Version: 1.0 11 | 12 | --buzzbuzzbuzzbuzzbuzzbuzz 13 | Content-type: text/plain; charset=utf-8 14 | 15 | {{{textPage}}} 16 | 17 | --buzzbuzzbuzzbuzzbuzzbuzz 18 | Content-type: text/html 19 | 20 | {{{htmlPage}}} 21 | 22 | --buzzbuzzbuzzbuzzbuzzbuzz-- 23 | {{#each attachments}} 24 | --beesbeesbeesbeesbeesbees 25 | Content-Type: {{{mimeType}}}; name="{{{filename}}}" 26 | Content-Transfer-Encoding: base64 27 | Content-ID: <{{{contentId}}}> 28 | Content-Disposition: inline; filename="{{{filename}}}" 29 | 30 | {{{data}}} 31 | {{/each}} 32 | --beesbeesbeesbeesbeesbees-- 33 | Trash after message 34 | -------------------------------------------------------------------------------- /newrelic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * New Relic agent configuration. 5 | * 6 | * See lib/config.defaults.js in the agent distribution for a more complete 7 | * description of configuration variables and their potential values. 8 | */ 9 | exports.config = { 10 | /** 11 | * Array of application names. 12 | */ 13 | app_name: [process.env.NEW_RELIC_APP_NAME], 14 | /** 15 | * Your New Relic license key. 16 | */ 17 | license_key: process.env.NEW_RELIC_LICENSE_KEY, 18 | logging: { 19 | /** 20 | * Level at which to log. 'trace' is most useful to New Relic when diagnosing 21 | * issues with the agent, 'info' and higher will impose the least overhead on 22 | * production applications. 23 | */ 24 | level: 'info' 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beeline-server", 3 | "version": "0.0.0", 4 | "description": "", 5 | "engines": { 6 | "node": "9.x.x" 7 | }, 8 | "main": "dist/index.js", 9 | "scripts": { 10 | "start": "node dist/index.js", 11 | "watch": "nodemon -x babel-node src/index.js", 12 | "lint": "eslint --fix src/", 13 | "clean": "rm -rf dist/", 14 | "build": "babel src/ --out-dir dist/", 15 | "heroku-postbuild": "npm run clean && npm run build", 16 | "test": "pg_virtualenv scripts/setup_virtualenv.sh", 17 | "test_current": "PULL_DATABASE=live pg_virtualenv scripts/setup_virtualenv.sh", 18 | "test_cached": "PULL_DATABASE=cache pg_virtualenv scripts/setup_virtualenv.sh", 19 | "prettier-lint": "bash scripts/prettier-lint.sh" 20 | }, 21 | "author": "Government Technology Agency of Singapore (https://www.tech.gov.sg)", 22 | "license": "MIT", 23 | "dependencies": { 24 | "@mapbox/togeojson": "^0.16.0", 25 | "@opengovsg/ura-subzones": "^1.0.0", 26 | "axios": "^0.18.0", 27 | "babel-cli": "^6.26.0", 28 | "babel-plugin-transform-runtime": "^6.6.0", 29 | "babel-polyfill": "^6.5.0", 30 | "babel-preset-es2015": "^6.5.0", 31 | "babel-preset-stage-2": "^6.11.0", 32 | "babel-runtime": "^6.6.1", 33 | "bluebird": "^3.3.3", 34 | "boom": "^5.1.0", 35 | "code": "^5.2.0", 36 | "commonmark": "^0.26.0", 37 | "dateformat": "^2.0.0", 38 | "fast-csv": "^2.4.0", 39 | "handlebars": "^4.0.5", 40 | "hapi": "^16.6.3", 41 | "hapi-swagger": "^7.7.1", 42 | "hoek": "^4.2.1", 43 | "inert": "^4.0.2", 44 | "inquirer": "^2.0.0", 45 | "joi": "^10.6.0", 46 | "jsdom": "^11.1.0", 47 | "jsonwebtoken": "^8.2.1", 48 | "left-pad": "^1.0.1", 49 | "lodash": "^4.17.11", 50 | "mimos": "^3.0.3", 51 | "moment": "^2.22.1", 52 | "ngeohash": "^0.6.0", 53 | "node-ical": "^0.7.0", 54 | "node-telegram-bot-api": "^0.30.0", 55 | "nodemailer": "^2.5.0", 56 | "pg": "^6.4.2", 57 | "polyline": "^0.2.0", 58 | "proj4": "^2.3.12", 59 | "promise": "^7.1.1", 60 | "q": "^1.4.1", 61 | "seedrandom": "^2.4.2", 62 | "sequelize": "^3.33.0", 63 | "sharp": "^0.21.1", 64 | "sinon": "^1.17.7", 65 | "smtp-connection": "^2.3.1", 66 | "smtp-server": "^1.9.0", 67 | "source-map-support": "^0.4.0", 68 | "ssacl-attribute-roles": "0.0.5", 69 | "strip-bom-stream": "^2.0.0", 70 | "stripe": "^4.4.0", 71 | "timemachine": "^0.2.8", 72 | "twilio": "^2.9.0", 73 | "typescript": "^1.8.2", 74 | "uuid": "^3.1.0", 75 | "vision": "^4.1.0", 76 | "xmldom": "^0.1.22" 77 | }, 78 | "devDependencies": { 79 | "babel-eslint": "^7.2.3", 80 | "clear": "0.0.1", 81 | "eslint": "^4.14.0", 82 | "eslint-config-google": "^0.9.1", 83 | "eslint-config-prettier": "^2.9.0", 84 | "eslint-plugin-babel": "^4.1.2", 85 | "estraverse-fb": "^1.3.2", 86 | "lab": "^13.1.0", 87 | "lab-babel": "^1.1.1", 88 | "nodemon": "^1.18.6", 89 | "pre-commit": "^1.2.2", 90 | "prettier": "1.9.2" 91 | }, 92 | "pre-commit": [ 93 | "prettier-lint" 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /run-branch-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "" ] 4 | then 5 | echo "Please specify the branch whose tests you want to run" 6 | fi 7 | 8 | if [ "$2" = "" ] 9 | then 10 | TEST_WHAT=test 11 | else 12 | TEST_WHAT="$2" 13 | fi 14 | 15 | mkdir -p test-old 16 | 17 | git archive "$1" ./test | tar -x -C test-old --strip-components=1 test 18 | 19 | TESTS=test-old npm run "$TEST_WHAT" 20 | 21 | -------------------------------------------------------------------------------- /scripts/charge-id-csv.js: -------------------------------------------------------------------------------- 1 | const {db, models: m} = require('../src/lib/core/dbschema')(); 2 | const _ = require('lodash'); 3 | const csv = require('fast-csv'); 4 | 5 | async function dumpChargeIds (fromWhen, toWhen) { 6 | var transactionItems = await m.TransactionItem.findAll({ 7 | where: { 8 | itemType: 'payment' 9 | }, 10 | include: [ 11 | { 12 | model: m.Transaction, 13 | where: {committed: true}, 14 | attributes: [] 15 | } 16 | ], 17 | raw: true 18 | }); 19 | var payments = await m.Payment.findAll({ 20 | where: {id: {$in: transactionItems.map(ti => ti.itemId)}}, 21 | raw: true 22 | }); 23 | var paymentsById = _.keyBy(payments, 'id'); 24 | 25 | return transactionItems.map(ti => [ 26 | paymentsById[ti.itemId] && paymentsById[ti.itemId].paymentResource, 27 | ti.transactionId 28 | ]); 29 | } 30 | 31 | dumpChargeIds('2016-05-01', '2016-09-01') 32 | .then((result) => { 33 | var csvStream = csv.createWriteStream({headers: true}); 34 | 35 | csvStream.pipe(process.stdout); 36 | for (let row of result) { 37 | csvStream.write({ 38 | chargeId: row[0], 39 | originalTransaction: row[1], 40 | }); 41 | } 42 | csvStream.end(); 43 | }) 44 | .catch((err) => { 45 | console.error(err.stack); 46 | }); 47 | -------------------------------------------------------------------------------- /scripts/login-as.js: -------------------------------------------------------------------------------- 1 | 2 | require('bluebird').config({cancellation: true}); 3 | const tc = require("../test/test_common"); 4 | const {db, models} = require("../src/lib/core/dbschema")(); 5 | 6 | if (process.argv.length < 3 || 7 | (process.argv[2] != "superadmin" && process.argv.length < 4)) { 8 | console.error(` 9 | Syntax: 10 | 11 | (1) As Driver 12 | export DATABASE_URL=postgres://.../ 13 | babel-node login-as.js driver 14 | 15 | (2) As User 16 | export DATABASE_URL=postgres://.../ 17 | babel-node login-as.js user 18 | 19 | (3) As Admin 20 | export DATABASE_URL=postgres://.../ 21 | babel-node login-as.js admin 22 | 23 | (4) As Superadmin 24 | export DATABASE_URL=postgres://.../ 25 | babel-node login-as.js superadmin 26 | `); 27 | process.exit(1); 28 | } 29 | 30 | if (process.argv[2] == "driver") { 31 | models.Driver.findById(process.argv[3]) 32 | .then((driver) => { 33 | console.log(driver.makeToken()); 34 | }) 35 | .then(process.exit) 36 | .then(null, (error) => console.error(error)); 37 | } else if (process.argv[2] == "user") { 38 | models.User.find({ 39 | where: { 40 | telephone: process.argv[3] 41 | } 42 | }) 43 | .then((user) => { 44 | console.log(user.makeToken()); 45 | }) 46 | .then(process.exit) 47 | .then(null, (error) => console.error(error)); 48 | } else if (process.argv[2] == "admin") { 49 | tc.loginAs("admin", { 50 | transportCompanyId: parseInt(process.argv[3]) 51 | }, null) 52 | .then(response => console.log(response.result.sessionToken)) 53 | .then(process.exit) 54 | .then(null, (error) => console.error(error)); 55 | } else if (process.argv[2] == "superadmin") { 56 | tc.loginAs("superadmin", { 57 | }, null) 58 | .then(response => console.log(response.result.sessionToken)) 59 | .then(process.exit) 60 | .then(null, (error) => console.error(error)); 61 | } 62 | -------------------------------------------------------------------------------- /scripts/makedumps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pg_dump beeline2 --no-owner --no-acl > dbdump.sql 4 | pg_dump beeline2 -F tar --no-owner --no-acl > dbdump.tar 5 | -------------------------------------------------------------------------------- /scripts/post_dump.sql: -------------------------------------------------------------------------------- 1 | 2 | -- Suppress all existing event subscriptions 3 | DELETE FROM "eventSubscriptions"; 4 | -------------------------------------------------------------------------------- /scripts/prettier-lint.sh: -------------------------------------------------------------------------------- 1 | printf "\n------------------------------------------------------\n\n" 2 | printf "Running pre-commit hook...\n\n" 3 | 4 | git fetch 5 | 6 | src_dir='src/*.js' 7 | diff_filenames=$(git diff --name-only --cached origin/master -- "$src_dir") 8 | 9 | if [ -z "$diff_filenames" ] 10 | then 11 | printf "No file changes detected!\n" 12 | else 13 | printf "Running prettier and eslint on the following JS files in this branch...\n" 14 | printf "%b\n" "$diff_filenames" 15 | printf "\n" 16 | 17 | prettier_diff=$(./node_modules/prettier/bin/prettier.js --list-different $diff_filenames) 18 | 19 | if [ -z "$prettier_diff" ] 20 | then 21 | printf "No changes after running prettier" 22 | else 23 | printf "Running prettier on the following files:\n" 24 | ./node_modules/prettier/bin/prettier.js --write $prettier_diff 25 | fi 26 | 27 | printf "\nRunning ESLint with --fix option... " 28 | 29 | # Detect if eslint can autofix 30 | ./node_modules/eslint/bin/eslint.js --fix $diff_filenames 31 | 32 | printf "Done!\n" 33 | fi 34 | 35 | printf "\nPre-commit hook completed\n" 36 | printf "\n------------------------------------------------------\n\n" -------------------------------------------------------------------------------- /scripts/refund-id-csv.js: -------------------------------------------------------------------------------- 1 | const {db, models: m} = require('../src/lib/core/dbschema')(); 2 | const _ = require('lodash'); 3 | const csv = require('fast-csv'); 4 | 5 | async function dumpRefundsOriginalTransactions (fromWhen, toWhen) { 6 | // Find only committed transactions 7 | var transactions = await m.Transaction.findAll({ 8 | where: { 9 | createdAt: {$gte: fromWhen, $lte: toWhen}, 10 | committed: true 11 | }, 12 | include: [m.TransactionItem] 13 | }); 14 | 15 | transactions = transactions 16 | // Get only transactions with refunds 17 | .filter(t => _.some(t.transactionItems, ti => ti.itemType === 'refundPayment')) 18 | .map(t => t.toJSON()); 19 | 20 | var refundPaymentIds = _(transactions) 21 | .map(t => t.transactionItems) 22 | .flatten() 23 | .filter(t => t.itemType === 'refundPayment') 24 | .map(t => t.itemId) 25 | .value(); 26 | 27 | var refundPayments = await m.RefundPayment.findAll({ 28 | where: {id: {$in: refundPaymentIds}}, 29 | raw: true 30 | }); 31 | var refundPaymentsById = _.keyBy(refundPayments, 'id'); 32 | 33 | var ticketsWithRefund = _(transactions) 34 | .map(t => t.transactionItems) 35 | .flatten() 36 | .filter(ti => ti.itemType === 'ticketRefund') 37 | .value(); 38 | 39 | var refundTicketIds = ticketsWithRefund.map(tk => tk.itemId); 40 | var associatedTransactions = await m.TransactionItem.findAll({ 41 | where: { 42 | itemType: 'ticketSale', 43 | itemId: {$in: refundTicketIds} 44 | }, 45 | attributes: ['itemId', 'transactionId'], 46 | include: [ 47 | { 48 | model: m.Transaction, 49 | where: {committed: true}, 50 | attributes: [] 51 | } 52 | ], 53 | raw: true 54 | }); 55 | var saleTransactionIdByTicketId = _.keyBy(associatedTransactions, 'itemId'); 56 | 57 | // Output [stripeRefundId, transactionId] 58 | var output = []; 59 | for (let transaction of transactions) { 60 | var refundId = transaction.transactionItems.find(ti => ti.itemType === 'refundPayment').itemId; 61 | 62 | for (let ti of transaction.transactionItems) { 63 | if (ti.itemType === 'ticketRefund' && ti.itemId in saleTransactionIdByTicketId) { 64 | output.push([ 65 | refundPaymentsById[refundId].paymentResource, 66 | transaction.id, 67 | saleTransactionIdByTicketId[ti.itemId].transactionId 68 | ]); 69 | } 70 | } 71 | } 72 | 73 | return output; 74 | } 75 | 76 | dumpRefundsOriginalTransactions('2016-05-01', '2016-09-01') 77 | .then((result) => { 78 | var csvStream = csv.createWriteStream({headers: true}); 79 | 80 | csvStream.pipe(process.stdout); 81 | for (let row of result) { 82 | csvStream.write({ 83 | stripeRefundId: row[0], 84 | originalTransaction: row[1], 85 | refundTransaction: row[2] 86 | }); 87 | } 88 | csvStream.end(); 89 | }) 90 | .catch((err) => { 91 | console.error(err.stack); 92 | }); 93 | -------------------------------------------------------------------------------- /scripts/send-trip-to.js: -------------------------------------------------------------------------------- 1 | 2 | import {server} from "../index"; 3 | import tc from "../test/test_common"; 4 | import {tokenFromInstance as makeDriverToken} from "../lib/endpoints/drivers"; 5 | import {tokenFromInstance as makeUserToken} from "../lib/endpoints/users"; 6 | import querystring from "querystring"; 7 | const {db, models} = require("../lib/core/dbschema")(); 8 | 9 | if (process.argv.length < 4) { 10 | console.error(` 11 | Syntax: 12 | 13 | (1) As Driver 14 | export DATABASE_URL=postgres://.../ 15 | babel-node send-trip-to.js +65 16 | 17 | `); 18 | process.exit(1); 19 | } 20 | 21 | tc.loginAs("superadmin") 22 | .then(response => { 23 | var headers = { 24 | authorization: `Bearer ${response.result.sessionToken}`, 25 | }; 26 | 27 | return server.inject({ 28 | url: `/trips/${process.argv[2]}/send_to_phone?` + querystring.stringify({ 29 | telephone: process.argv[3] 30 | }), 31 | method: "POST", 32 | headers: headers, 33 | }); 34 | }) 35 | .then(process.exit) 36 | .then(null, (error) => console.error(error)); 37 | -------------------------------------------------------------------------------- /scripts/setup_virtualenv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script is meant to be run using `pg_virtualenv` 3 | # Alternatively, set PGUSER, PGPASSWORD, PGHOST, PGPORT and PGDATABASE 4 | # before running this script 5 | 6 | set -eou pipefail 7 | 8 | export DATABASE_URL=postgres://$PGUSER:$PGPASSWORD@$PGHOST:$PGPORT/$PGDATABASE 9 | export ROUTING_SERVER_URL=https://routing.beeline.sg 10 | export PGSSLMODE=allow 11 | 12 | # Fake credentials for the other services 13 | # (should not affect testing since we have dryRun=1) 14 | export SMTP_HOST=localhost 15 | export SMTP_PORT=25 16 | export SMTP_SECURE=0 17 | export SMTP_USER=username 18 | export SMTP_PASSWORD=password 19 | 20 | export TWILIO_ACCOUNT_SID="FAKE TWILIO SID" 21 | export TWILIO_AUTH_TOKEN="FAKE TWILIO AUTH" 22 | 23 | export WEB_DOMAIN=testing.beeline.sg 24 | export EMAIL_DOMAIN=testing.beeline.sg 25 | 26 | export STRIPE_PK=pk_test_QLotzyBvP72TlRJN6JbF5rF2 27 | export STRIPE_CID=ca_7LJOw1zPE4ZuiWfoJ5LVIdiIs1b7w8w5 28 | export STRIPE_SK=sk_test_rPpgKrMO8mO5p7ke76nP1NIR 29 | export STRIPE_MICRO_RATES=true 30 | export STRIPE_TEST_DESTINATION=acct_17zcVUIt6Q7WukI6 31 | 32 | export GOOGLE_MAPS_API_KEY=AIzaSyB7YgUElOrvSlUvdML67lTYouScsQ0TYeQ 33 | 34 | export AUTH0_CID=BslsfnrdKMedsmr9GYkTv7ejJPReMgcE 35 | export AUTH0_DOMAIN=beeline.au.auth0.com 36 | export AUTH0_SECRET=what 37 | export PUBLIC_AUTH0_SECRET=whatwhatwhatwhat 38 | export AUTH0_TOKEN_USERREAD=what 39 | 40 | export TEST_IDEMPOTENCY=$(date '+%s') 41 | 42 | export NO_DAEMON_MONITORING=1 43 | export ROUTES_REFRESH_INTERVAL=1 # cache routes only for 1 ms 44 | 45 | export BEELINE_COMPANY_ID=1 46 | 47 | # Import the live data for testing 48 | refresh_cache() { 49 | if [ -z "${DATABASE_SOURCE:-}" ]; then 50 | DATABASE_SOURCE='postgres://postgres:SePRSWpG+ER6NTGoCH1vBUf15IA@staging.beeline.sg:5432/beelinetest' 51 | fi 52 | echo "Updating database dump" 53 | pg_dump -x -O "$DATABASE_SOURCE" > database_dump.sql 54 | } 55 | 56 | if [ "${PULL_DATABASE:-}" = "live" ] 57 | then 58 | refresh_cache 59 | cat database_dump.sql | psql $DATABASE_URL 60 | cat scripts/post_dump.sql | psql $DATABASE_URL 61 | node scripts/update_sequences.js 62 | elif [ "${PULL_DATABASE:-}" = "cache" ] 63 | then 64 | if [ ! -e database_dump.sql ] 65 | then 66 | refresh_cache 67 | fi 68 | cat database_dump.sql | psql $DATABASE_URL 69 | cat scripts/post_dump.sql | psql $DATABASE_URL 70 | node scripts/update_sequences.js 71 | else 72 | echo "CREATE EXTENSION postgis" | psql $DATABASE_URL 73 | babel-node scripts/db_init.js 74 | fi 75 | echo 'Database initialized' 76 | 77 | if [ -z "${TESTS:-}" ] 78 | then 79 | TESTS=test/ 80 | fi 81 | 82 | # npm run actual_test 83 | node_modules/lab/bin/lab ${LAB_OPTIONS:-} -T node_modules/lab-babel --globals BigUint64Array,BigInt64Array,BigInt,URL,URLSearchParams,SharedArrayBuffer,Atomics,WebAssembly,__core-js_shared__ -S $TESTS 84 | -------------------------------------------------------------------------------- /scripts/trial-AM.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | const {db: sequelize, models} = require('../src/lib/core/dbschema')(); 4 | 5 | Date.prototype.toLocalString = function () { 6 | return new Date(this.getTime() - 60000 * this.getTimezoneOffset()) 7 | .toISOString(); 8 | }; 9 | 10 | async function create () { 11 | try { 12 | await sequelize.transaction(async txn => { 13 | var t = {transaction: txn}; 14 | var m = models; 15 | var transportCompany = await m.TransportCompany.findOne(); 16 | var routes = _.sortBy(await m.Route.findAll(), 'id'); 17 | var stops = _.sortBy(await m.Stop.findAll(), 'id'); 18 | // console.log(transportCompany) 19 | // console.log(routes.map(x => x.toJSON())) 20 | // console.log(stops.map(x => x.toJSON())) 21 | 22 | var trips = []; 23 | var iteration = -1; 24 | for (var timeSlot = 0; timeSlot < 12; timeSlot = timeSlot + 4) { 25 | // create up to 100 days into the future? 26 | // only on weekdays! 27 | iteration++; 28 | var startDateInMillsec = new Date("2016-05-20").getTime(); 29 | var i = 0; 30 | while (true) { 31 | var day = new Date(startDateInMillsec + 24 * 60 * 60000 * i); 32 | if (day.getTime() > startDateInMillsec) { break; } // exceed the end date 33 | i = i + 1; 34 | 35 | let tripZ1A = await models.Trip.create({ 36 | date: day.toLocalString().substr(0, 10), 37 | capacity: 20, 38 | transportCompanyId: transportCompany.id, 39 | price: 3, 40 | routeId: routes[timeSlot].id, // Tampines,Bedok to zoo 41 | }, t); 42 | 43 | var tripStops = [ 44 | await models.TripStop.create({ 45 | tripId: tripZ1A.id, 46 | stopId: stops[0].id, // tampines 47 | canBoard: true, 48 | canAlight: false, 49 | time: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 50 | iteration + 9, 0, 0), 51 | }, t), 52 | 53 | await models.TripStop.create({ 54 | tripId: tripZ1A.id, 55 | stopId: stops[1].id, // Bedok MRT 56 | canBoard: true, 57 | canAlight: false, 58 | time: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 59 | iteration + 9, 20, 0), 60 | }, t), 61 | 62 | await models.TripStop.create({ 63 | tripId: tripZ1A.id, 64 | stopId: stops[3].id, // zoo 65 | canBoard: false, 66 | canAlight: true, 67 | time: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 68 | iteration + 9, 50, 0), 69 | }, t), 70 | 71 | ]; 72 | await tripZ1A.setTripStops(tripStops, t); 73 | tripZ1A.tripStops = tripStops; 74 | 75 | trips.push(tripZ1A); 76 | 77 | 78 | let tripZ3A = await models.Trip.create({ 79 | date: day.toLocalString().substr(0, 10), 80 | capacity: 20, 81 | transportCompanyId: transportCompany.id, 82 | price: 3, 83 | routeId: routes[timeSlot + 2].id, // Sengkang to Zoo 84 | }, t); 85 | 86 | tripStops = [ 87 | // zoo stop 88 | await models.TripStop.create({ 89 | tripId: tripZ3A.id, 90 | stopId: stops[2].id, // Sengkang 91 | canBoard: true, 92 | canAlight: false, 93 | time: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 94 | iteration + 9, 0, 0), 95 | }, t), 96 | 97 | // tampines stop 98 | await models.TripStop.create({ 99 | tripId: tripZ3A.id, 100 | stopId: stops[3].id, // zoo 101 | canBoard: false, 102 | canAlight: true, 103 | time: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 104 | iteration + 9, 20, 0), 105 | }, t), 106 | 107 | ]; 108 | await tripZ3A.setTripStops(tripStops, t); 109 | tripZ3A.tripStops = tripStops; 110 | 111 | trips.push(tripZ3A); 112 | } 113 | } 114 | }); 115 | } catch (err) { 116 | console.error(err.stack); 117 | } 118 | } 119 | 120 | 121 | Promise.resolve(null) 122 | .then(create) 123 | .then(() => { 124 | process.exit(); 125 | }) 126 | .catch(err => { 127 | console.log(err.stack); 128 | process.exit(1); 129 | }); 130 | -------------------------------------------------------------------------------- /scripts/update_sequences.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var pg = require('pg'); 4 | var client = new pg.Client(process.env.DATABASE_URL); 5 | 6 | client.connect(function (err) { 7 | if (err) throw err; 8 | 9 | client.query('SELECT relname FROM pg_class WHERE relkind=\'S\'', function (err, result) { 10 | if (err) throw err; 11 | 12 | updateSequences(result.rows); 13 | }); 14 | 15 | function updateSequences (rows) { 16 | var updates = []; 17 | 18 | for (let row of rows) { 19 | var match = row.relname.match(/^(.*)_id_seq/); 20 | if (match) { 21 | var sequenceName = match[0]; 22 | var tableName = match[1]; 23 | var query = `SELECT setval('"${sequenceName}"', (SELECT MAX(id) FROM "${tableName}"), true);`; 24 | console.log(query); 25 | updates.push(query); 26 | } 27 | } 28 | 29 | updates = updates.map(upd => new Promise((resolve, reject) => { 30 | client.query(upd, (err, res) => { 31 | if (err) { return reject(err); } else { return resolve(); } 32 | }); 33 | })); 34 | 35 | Promise.all(updates) 36 | .then(() => { 37 | process.exit(0); 38 | console.log('DONE!'); 39 | }) 40 | .catch((err) => console.log(err)); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /src/lib/aws/expireStaleRoutePasses.js: -------------------------------------------------------------------------------- 1 | const {db, models} = require('../core/dbschema')() 2 | const {TransactionBuilder} = require('../transactions/builder') 3 | /** 4 | * Finds and expires rp-prefixed route credits from a specified company 5 | * older than a specified date 6 | */ 7 | exports.handler = function (event, context, callback) { 8 | return db.transaction(transaction => { 9 | return expireRoutePasses(transaction) 10 | .then(affectedEntries => insertExpiredCreditTransactions(transaction, affectedEntries)) 11 | .then(payload => callback(null, JSON.stringify(payload, null, 2))) 12 | .catch(callback) 13 | }) 14 | } 15 | 16 | function expireRoutePasses (transaction) { 17 | return db.query( 18 | ` 19 | UPDATE 20 | "routePasses" rp 21 | SET 22 | status = 'expired' 23 | WHERE 24 | status = 'valid' 25 | AND now()::date > rp."expiresAt"::date 26 | RETURNING * 27 | `, 28 | { 29 | raw: true, 30 | transaction, 31 | } 32 | ) 33 | } 34 | 35 | 36 | function insertExpiredCreditTransactions (transaction, affectedEntries) { 37 | return models.Account.getByName('Upstream Route Credits', {attributes: ['id'], transaction}) 38 | .then(account => { 39 | return Promise.all(affectedEntries[0].map(entry => { 40 | const tb = new TransactionBuilder({ 41 | db, models, transaction, 42 | committed: true, dryRun: false, 43 | creator: {type: 'system', id: 'expireRoutePasses'} 44 | }) 45 | 46 | // On the debit side 47 | tb.lineItems = null 48 | tb.description = `Expire route pass ${entry.id}` 49 | 50 | const amount = entry.notes.price 51 | 52 | tb.transactionItemsByType = { 53 | routePass: [{ 54 | itemType: 'routePass', 55 | itemId: entry.id, 56 | debit: amount, 57 | }], 58 | account: [{ 59 | itemType: 'account', 60 | itemId: account.id, 61 | credit: amount 62 | }] 63 | } 64 | 65 | return tb.build({type: 'routePassExpiry'}).then(([t]) => [entry, t.toJSON()]) 66 | })) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/aws/smoketest.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const email = require("../util/email") 3 | const { db } = require("../core/dbschema")() 4 | 5 | const ticketCountAgreesWithAvailability = db => 6 | db.query( 7 | ` 8 | WITH availability as (SELECT trips.id AS "tripId", 9 | trips.capacity AS "seatsTotal", 10 | (trips.capacity - count("tripStops".id))::integer AS "seatsAvailable", 11 | count("tripStops".id)::integer AS "seatsBooked" 12 | FROM trips 13 | LEFT JOIN (( SELECT tickets_1.id AS "ticketId", 14 | tickets_1."userId", 15 | tickets_1."boardStopId", 16 | tickets_1."alightStopId", 17 | tickets_1.status 18 | FROM tickets tickets_1 19 | WHERE tickets_1.status::text IN ('valid'::text, 'pending'::text, 'bidded'::text)) tickets 20 | JOIN "tripStops" ON tickets."boardStopId" = "tripStops".id) ON trips.id = "tripStops"."tripId" 21 | GROUP BY trips.id, trips.capacity) 22 | select t.id, capacity, t."seatsAvailable" as "tripSeatsAvailable", a."seatsAvailable", r.label, r.from, r.to 23 | from routes r, trips t, availability a 24 | where t.id = a."tripId" 25 | and t."seatsAvailable" != a."seatsAvailable" 26 | and t.date > now() 27 | and t."routeId" = r.id 28 | and not (r.tags @> ARRAY['crowdstart']::varchar[]) 29 | `, 30 | { type: db.QueryTypes.SELECT } 31 | ) 32 | 33 | const allPaymentsHaveData = db => 34 | db.query( 35 | `select t.*, p."paymentResource" 36 | from tickets t, "transactionItems" tip, "transactionItems" tis, payments p 37 | where p.data is null 38 | and p.id = tip."itemId" and tip."itemType" = 'payment' 39 | and tip."transactionId" = tis."transactionId" 40 | and tis."itemId" = t.id and tis."itemType" = 'ticketSale' 41 | and t.status = 'valid' and t."createdAt" > '2017-01-01' 42 | order by t."createdAt" desc 43 | `, 44 | { type: db.QueryTypes.SELECT } 45 | ) 46 | 47 | const conductSmokeTestAndEmail = function conductSmokeTestAndEmail(db) { 48 | return Promise.all([ 49 | ticketCountAgreesWithAvailability(db), 50 | allPaymentsHaveData(db), 51 | ]) 52 | .then(async ([badAvailTrips, badTickets]) => { 53 | let mailText = "" 54 | if (badAvailTrips.length > 0) { 55 | mailText += 56 | "The following trips report seat availability different from what is actually available:\n" + 57 | badAvailTrips.map(t => JSON.stringify(t) + "\n") 58 | } 59 | if (badTickets.length > 0) { 60 | mailText += 61 | "\n" + 62 | "The following tickets lack payments:\n" + 63 | badTickets.map(t => JSON.stringify(t) + "\n") 64 | } 65 | 66 | console.log(mailText || "Smoketest complete - No Problems") 67 | if (mailText) { 68 | const mail = { 69 | from: "smoketest-noreply@beeline.sg", 70 | to: process.env.EMAIL || "admin@beeline.sg", 71 | subject: 72 | "Smoke test " + 73 | new Date().toISOString().split("T")[0] + 74 | ": " + 75 | (mailText ? "Problems Found" : "No Problems"), 76 | text: mailText, 77 | } 78 | 79 | email 80 | .sendMail(mail) 81 | .then(info => console.log("Mail sent.")) 82 | .catch(err => console.log("Mail send failure: " + err)) 83 | } 84 | }) 85 | .catch(err => console.error(err)) 86 | } 87 | 88 | exports.conductSmokeTestAndEmail = conductSmokeTestAndEmail 89 | exports.handler = function(event, context, callback) { 90 | return conductSmokeTestAndEmail(db) 91 | .then(() => callback(null, "Complete")) 92 | .catch(callback) 93 | } 94 | -------------------------------------------------------------------------------- /src/lib/core/dbschema.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize") 2 | const assert = require("assert") 3 | 4 | let cache = null 5 | 6 | const ModelCache = require("./modelCache").default 7 | 8 | const genModelAndTasks = seq => { 9 | const modelCache = new ModelCache(seq) 10 | 11 | modelCache.require("TransportCompany") 12 | modelCache.require("Route") 13 | modelCache.require("RouteAnnouncement") 14 | modelCache.require("Stop") 15 | modelCache.require("Driver") 16 | modelCache.require("Vehicle") 17 | modelCache.require("Trip") 18 | modelCache.require("TripStop") 19 | modelCache.require("User") 20 | modelCache.require("Ticket") 21 | modelCache.require("Admin") 22 | modelCache.require("Transaction") 23 | modelCache.require("Account") 24 | modelCache.require("TransactionItem") 25 | modelCache.require("Discount") 26 | modelCache.require("Promotion") 27 | modelCache.require("Payment") 28 | modelCache.require("RefundPayment") 29 | modelCache.require("Transfer") 30 | modelCache.require("Suggestion") 31 | modelCache.require("SuggestedRoute") 32 | 33 | modelCache.require("Alert") 34 | modelCache.require("Asset") 35 | modelCache.require("AdminCompany") 36 | modelCache.require("DriverCompany") 37 | modelCache.require("Subscription") 38 | modelCache.require("IndicativeTrip") 39 | modelCache.require("EventSubscription") 40 | modelCache.require("RouteCredit") 41 | modelCache.require("RoutePass") 42 | modelCache.require("PromoUsage") 43 | modelCache.require("ContactList") 44 | modelCache.require("Bid") 45 | modelCache.require("UserSuggestedRoute") 46 | modelCache.require("UserSuggestedRouteStop") 47 | 48 | modelCache.makeAssociations() 49 | return modelCache 50 | } 51 | 52 | const dbLogin = () => { 53 | // Grab the config parameters 54 | let sequelizeOptions = { 55 | logging: process.env.SHUTUP ? false : console.warn, 56 | pool: { 57 | max: Number(process.env.MAX_DB_CONNECTIONS) || 5, 58 | min: 0, 59 | }, 60 | } 61 | 62 | // Find read replicas 63 | let replicas = [] 64 | for (let key in process.env) { 65 | if (key.startsWith("HEROKU_POSTGRESQL")) { 66 | replicas.push(process.env[key]) 67 | } 68 | } 69 | const parseReplica = replica => { 70 | // postgres URLs take the form postgres://user:password@host:port/database 71 | const parts = replica.match( 72 | /^postgres:\/\/(.+):(.+)@(.+):([0-9]{1,6})\/(.+)$/ 73 | ) 74 | assert(parts) 75 | const [, username, password, host, port, database] = parts 76 | return { host, port, username, password, database } 77 | } 78 | if (replicas.length > 0) { 79 | sequelizeOptions = { 80 | ...sequelizeOptions, 81 | replication: { 82 | read: replicas.map(parseReplica), 83 | write: parseReplica(process.env.DATABASE_URL), 84 | }, 85 | } 86 | } 87 | 88 | // Throw an error if we don't have a databse url to connect to 89 | if (!process.env.DATABASE_URL) { 90 | throw new Error("DATABASE_URL environmental variable not set") 91 | } 92 | 93 | // Creates the database connection and test it 94 | const sequelize = new Sequelize(process.env.DATABASE_URL, sequelizeOptions) 95 | sequelize.authenticate().catch(error => { 96 | console.error(error) 97 | process.exit(1) 98 | }) 99 | 100 | return sequelize 101 | } 102 | 103 | module.exports = function() { 104 | if (!cache) { 105 | const db = dbLogin() 106 | const modelAndTask = genModelAndTasks(db, db.Sequelize) 107 | 108 | cache = { 109 | db: db, 110 | models: modelAndTask.models, 111 | syncTasks: modelAndTask.syncTasks, 112 | postSyncTasks: modelAndTask.postSyncTasks, 113 | } 114 | } 115 | 116 | return cache 117 | } 118 | -------------------------------------------------------------------------------- /src/lib/core/download.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | /* eslint-disable guard-for-in */ 3 | 4 | const _ = require("lodash") 5 | const auth = require("../core/auth") 6 | const jwt = require("jsonwebtoken") 7 | const Joi = require("joi") 8 | const Boom = require("boom") 9 | const assert = require("assert") 10 | const Request = require("request") 11 | 12 | module.exports = (server, options, next) => { 13 | server.route({ 14 | method: "POST", 15 | path: "/downloads", 16 | config: { 17 | description: 18 | "Creates a download link that is valid for a short time (10mins)", 19 | tags: ["api", "admin"], 20 | validate: { 21 | payload: { 22 | uri: Joi.string().required(), 23 | }, 24 | }, 25 | }, 26 | async handler(request, reply) { 27 | try { 28 | // Get the token 29 | const token = request.headers.authorization.split(" ")[1] 30 | const tokenPayload = auth.checkToken(token) 31 | 32 | assert(!tokenPayload.noExtend) 33 | 34 | // disallow anyone from extending the validity of the token 35 | tokenPayload.noExtend = true 36 | tokenPayload.uri = request.payload.uri 37 | 38 | const temporaryToken = jwt.sign( 39 | _.omit(tokenPayload, ["exp", "iat"]), 40 | auth.secretKey, 41 | { 42 | expiresIn: "10m", 43 | } 44 | ) 45 | 46 | return reply({ 47 | token: temporaryToken, 48 | }) 49 | } catch (err) { 50 | console.error(err.stack) 51 | reply(Boom.badImplementation()) 52 | } 53 | }, 54 | }) 55 | 56 | server.route({ 57 | method: "GET", 58 | path: "/downloads/{token}", 59 | config: { 60 | tags: ["api", "admin"], 61 | validate: { 62 | params: { 63 | token: Joi.string(), 64 | }, 65 | }, 66 | }, 67 | async handler(request, reply) { 68 | try { 69 | const t = jwt.decode(request.params.token) 70 | 71 | // leave the verification to the injected function 72 | Request({ 73 | url: `http://127.0.0.1:${request.connection.info.port}${t.uri}`, 74 | headers: { 75 | authorization: `Bearer ${request.params.token}`, 76 | }, 77 | }) 78 | .on("response", http => { 79 | const { PassThrough } = require("stream") 80 | const response = reply(http.pipe(new PassThrough())) 81 | for (let header in http.headers) { 82 | response.header(header, http.headers[header]) 83 | } 84 | }) 85 | .on("error", err => { 86 | console.error(err) 87 | reply(Boom.boomify(err)) 88 | }) 89 | } catch (err) { 90 | console.error(err.stack) 91 | reply(Boom.badImplementation()) 92 | } 93 | }, 94 | }) 95 | 96 | next() 97 | } 98 | 99 | module.exports.attributes = { 100 | name: "download", 101 | } 102 | -------------------------------------------------------------------------------- /src/lib/core/modelCache.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | 3 | export default class ModelCache { 4 | 5 | constructor (db) { 6 | this.db = db // the sequelize database connection 7 | this.models = {} 8 | this.syncTasks = [] 9 | this.postSyncTasks = [] 10 | this.makeAssociationFunctions = [] 11 | } 12 | 13 | require (modelName) { 14 | // model has been created before 15 | if (this.models[modelName]) { 16 | return this.models[modelName] 17 | } 18 | if (this.models[modelName] === false) { 19 | throw new Error("Cyclic dependency!") 20 | } 21 | 22 | // not created before 23 | // set to false -- cyclic dependency check 24 | this.models[modelName] = false 25 | var modelModule = require('../models/' + modelName) 26 | var modelDefinition = modelModule.default 27 | var model = modelDefinition(this) 28 | 29 | // ensure model is a well-defined object 30 | assert(model) 31 | this.models[modelName] = model 32 | 33 | // do not sync if dontSync == true 34 | // used for views 35 | if (!modelModule.dontSync) { 36 | this.syncTasks.push((t) => model.sync({transaction: t})) 37 | } 38 | 39 | // if the module wants a post-sync function 40 | if (modelModule.postSync) { 41 | this.postSyncTasks = this.postSyncTasks.concat(modelModule.postSync) 42 | } 43 | 44 | // make association functions 45 | if (modelModule.makeAssociation) { 46 | this.makeAssociationFunctions.push(modelModule.makeAssociation) 47 | } 48 | } 49 | 50 | /** 51 | Negates DECIMAL types as well as Number types 52 | **/ 53 | neg (decimalValue) { 54 | if (decimalValue == null) { return null } 55 | 56 | if (typeof decimalValue === 'number') { 57 | return -decimalValue 58 | } else { 59 | if (decimalValue.startsWith('-')) { 60 | return decimalValue.substr(1) 61 | } else { 62 | return '-' + decimalValue 63 | } 64 | } 65 | } 66 | 67 | makeAssociations () { 68 | for (let assocFn of this.makeAssociationFunctions) { 69 | assocFn(this) 70 | } 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/core/sequelize.js: -------------------------------------------------------------------------------- 1 | 2 | export function register (server, options, next) { 3 | var {db, models} = require("./dbschema")() 4 | 5 | server.expose("db", db) 6 | server.expose("models", models) 7 | 8 | server.ext("onRequest", function (request, reply) { 9 | server.log(["sequelize", "info"], "Sequelize connection created") 10 | reply.continue() 11 | }) 12 | 13 | next() 14 | } 15 | 16 | register.attributes = { 17 | name: "sequelize" 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/core/version.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash") 2 | const { getModels, defaultErrorHandler } = require("../util/common") 3 | 4 | module.exports = (server, options, next) => { 5 | server.route({ 6 | method: "GET", 7 | path: "/versionRequirements", 8 | config: { 9 | description: `Returns the minimum version requirements for known apps. 10 | Apps are responsible for prompting the user to upgrade`, 11 | tags: ["api", "commuter"], 12 | }, 13 | async handler(request, reply) { 14 | try { 15 | let m = getModels(request) 16 | let wantedAssetNames = [ 17 | "driverApp.minVersion", 18 | "driverApp.upgradeUrl.iOS", 19 | "driverApp.upgradeUrl.Android", 20 | "commuterApp.upgradeUrl.iOS", 21 | "commuterApp.upgradeUrl.Android", 22 | "commuterApp.minVersion", 23 | ] 24 | 25 | let assets = await m.Asset.findAll({ 26 | where: { id: { $in: wantedAssetNames } }, 27 | }) 28 | 29 | let requirements = { 30 | driverApp: { 31 | minVersion: "1.0.0", 32 | upgradeUrl: {}, 33 | }, 34 | commuterApp: { 35 | minVersion: "1.0.0", 36 | upgradeUrl: {}, 37 | }, 38 | } 39 | 40 | for (let asset of assets) { 41 | _.set(requirements, asset.id, asset.data.trim()) 42 | } 43 | 44 | reply(requirements) 45 | } catch (err) { 46 | defaultErrorHandler(reply)(err) 47 | } 48 | }, 49 | }) 50 | 51 | next() 52 | } 53 | 54 | module.exports.attributes = { 55 | name: "version", 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/daemons/eventSubscriptions.js: -------------------------------------------------------------------------------- 1 | import * as events from '../events/events' 2 | import {startPolling} from './scheduler' 3 | 4 | const updateInterval = 30 * 60000 5 | 6 | function updateEventSubscriptions ({models}) { 7 | return models.EventSubscription 8 | .findAll({raw: true}) 9 | .then(events.setSubscriptionList) 10 | } 11 | 12 | async function register (server, options, next) { 13 | const {models, db} = server.plugins.sequelize 14 | const pollOptions = { 15 | run: () => updateEventSubscriptions(server.plugins.sequelize), 16 | name: 'Reload event subscriptions', 17 | interval: updateInterval, 18 | } 19 | 20 | await pollOptions.run() 21 | startPolling(pollOptions) 22 | server.expose('reloadSubscriptions', pollOptions.run) 23 | 24 | next() 25 | } 26 | 27 | register.attributes = { 28 | name: 'daemon-event-subscriptions', 29 | dependencies: ['sequelize'], 30 | version: '1.0.0' 31 | } 32 | 33 | module.exports = { 34 | register, 35 | updateEventSubscriptions, 36 | updateInterval 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/daemons/scheduler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Schedules a polled task represented as the given object 3 | * This function never returns, and should never be awaited on 4 | * @param task - a task that has the following fields: 5 | * name - the name of the task to be logged in console 6 | * interval - the interval between invocations, in milliseconds 7 | * run - a 0-arg callback 8 | */ 9 | async function startPolling (task) { 10 | while (true) { 11 | try { 12 | await new Promise(resolve => // eslint-disable-line no-await-in-loop 13 | setTimeout(() => { 14 | console.log(`Running task: ${task.name} after ${task.interval}ms elapsed`) 15 | resolve(task.run()) 16 | }, task.interval)) 17 | } catch (err) { 18 | console.log(err.stack) 19 | } 20 | } 21 | } 22 | 23 | module.exports = { startPolling } 24 | -------------------------------------------------------------------------------- /src/lib/endpoints/assets.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash") 2 | const Joi = require("joi") 3 | const commonmark = require("commonmark") 4 | const ical = require("node-ical") 5 | 6 | const { 7 | handleRequestWith, 8 | instToJSONOrNotFound, 9 | assertFound, 10 | deleteInst, 11 | } = require("../util/endpoints") 12 | 13 | export const register = function register(server, options, next) { 14 | server.route({ 15 | method: "GET", 16 | path: "/assets", 17 | config: { 18 | tags: ["api", "admin", "commuter"], 19 | auth: false, 20 | }, 21 | handler: handleRequestWith((ignored, request, { db, models }) => 22 | models.Asset.findAll({ 23 | attributes: ["id", [db.fn("LEFT", db.col("data"), 100), "preview"]], 24 | }) 25 | ), 26 | }) 27 | 28 | server.route({ 29 | method: "GET", 30 | path: "/assets/{id}", 31 | config: { 32 | tags: ["api", "admin", "commuter"], 33 | auth: false, 34 | validate: { 35 | params: { 36 | id: Joi.string().required(), 37 | }, 38 | }, 39 | }, 40 | handler: handleRequestWith( 41 | (ignored, request, { db, models }) => 42 | models.Asset.findById(request.params.id), 43 | instToJSONOrNotFound 44 | ), 45 | }) 46 | 47 | const reader = new commonmark.Parser({ safe: true }) 48 | const writer = new commonmark.HtmlRenderer({ safe: true }) 49 | server.route({ 50 | method: "GET", 51 | path: "/assets/{id}/renderMarkdown", 52 | config: { 53 | tags: ["api", "admin", "commuter"], 54 | auth: false, 55 | validate: { 56 | params: { 57 | id: Joi.string().required(), 58 | }, 59 | }, 60 | }, 61 | handler: handleRequestWith( 62 | (ignored, request, { db, models }) => 63 | models.Asset.findById(request.params.id), 64 | assertFound, 65 | asset => reader.parse(asset.data), 66 | writer.render 67 | ), 68 | }) 69 | 70 | server.route({ 71 | method: "PUT", 72 | path: "/assets/{id}", 73 | config: { 74 | tags: ["api", "admin"], 75 | auth: { access: { scope: ["superadmin"] } }, 76 | validate: { 77 | params: { 78 | id: Joi.string().required(), 79 | }, 80 | payload: { 81 | data: Joi.string().required(), 82 | }, 83 | }, 84 | }, 85 | handler: handleRequestWith( 86 | (ignored, request, { models }) => 87 | models.Asset.findById(request.params.id), 88 | (asset, request, { models }) => 89 | asset 90 | ? asset.update({ data: request.payload.data }) 91 | : models.Asset.create({ 92 | data: request.payload.data, 93 | id: request.params.id, 94 | }) 95 | ), 96 | }) 97 | 98 | server.route({ 99 | method: "DELETE", 100 | path: "/assets/{id}", 101 | config: { 102 | tags: ["api", "admin"], 103 | auth: { access: { scope: ["superadmin"] } }, 104 | validate: { 105 | params: { 106 | id: Joi.string().required(), 107 | }, 108 | }, 109 | }, 110 | handler: handleRequestWith( 111 | (ignored, request, { models }) => 112 | models.Asset.findById(request.params.id), 113 | deleteInst 114 | ), 115 | }) 116 | 117 | server.route({ 118 | method: "GET", 119 | path: "/publicHolidays", 120 | config: { 121 | tags: ["api", "admin"], 122 | plugins: { 123 | hapiAuthorization: false, 124 | }, 125 | }, 126 | handler: handleRequestWith( 127 | (ignored, request, { models }) => models.Asset.findById("PublicHoliday"), 128 | assertFound, 129 | asset => ical.parseICS(asset.data), 130 | data => 131 | _(data) 132 | .values() 133 | .flatMap(({ start, end, summary }) => { 134 | const holidays = [] 135 | const day = 1000 * 60 * 60 * 24 136 | const endTime = Date.UTC( 137 | end.getFullYear(), 138 | end.getMonth(), 139 | end.getDate() 140 | ) 141 | 142 | let startTime = Date.UTC( 143 | start.getFullYear(), 144 | start.getMonth(), 145 | start.getDate() 146 | ) 147 | while (startTime < endTime) { 148 | let holidayDate = new Date(startTime) 149 | holidays.push({ date: holidayDate, summary: summary }) 150 | startTime = startTime + day 151 | } 152 | return holidays 153 | }) 154 | .value() 155 | ), 156 | }) 157 | 158 | next() 159 | } 160 | register.attributes = { 161 | name: "endpoint-assets", 162 | } 163 | -------------------------------------------------------------------------------- /src/lib/endpoints/companyContactLists.js: -------------------------------------------------------------------------------- 1 | import { InvalidArgumentError } from "../util/errors" 2 | import { 3 | authorizeByRole, 4 | handleRequestWith, 5 | instToJSONOrNotFound, 6 | deleteInst, 7 | } from "../util/endpoints" 8 | import { getModels } from "../util/common" 9 | const Joi = require("../util/joi") 10 | 11 | export const register = function register(server, options, next) { 12 | const authorize = authorizeByRole("manage-customers") 13 | 14 | const findContactListById = async request => { 15 | const m = getModels(request) 16 | const id = request.params.id 17 | const listId = request.params.listId 18 | 19 | const contactListInst = await m.ContactList.findById(listId) 20 | 21 | if (contactListInst) { 22 | InvalidArgumentError.assert( 23 | id === +contactListInst.transportCompanyId, 24 | "Telephone list " + listId + " does not belong to company " + id 25 | ) 26 | } 27 | 28 | return contactListInst 29 | } 30 | 31 | const findAllLists = request => { 32 | const m = getModels(request) 33 | return m.ContactList.findAll({ 34 | where: { transportCompanyId: request.params.id }, 35 | attributes: { exclude: ["telephones", "emails"] }, 36 | }) 37 | } 38 | 39 | server.route({ 40 | method: "GET", 41 | path: "/companies/{id}/contactLists", 42 | config: { 43 | tags: ["api", "admin"], 44 | auth: { access: { scope: ["admin", "superadmin"] } }, 45 | validate: { 46 | params: { 47 | id: Joi.number().integer(), 48 | }, 49 | }, 50 | }, 51 | handler: handleRequestWith(authorize, findAllLists, contactLists => 52 | contactLists.map(l => l.toJSON()) 53 | ), 54 | }) 55 | 56 | server.route({ 57 | method: "GET", 58 | path: "/companies/{id}/contactLists/{listId}", 59 | config: { 60 | tags: ["api", "admin"], 61 | auth: { access: { scope: ["admin", "superadmin"] } }, 62 | validate: { 63 | params: { 64 | id: Joi.number().integer(), 65 | listId: Joi.number().integer(), 66 | }, 67 | }, 68 | }, 69 | handler: handleRequestWith( 70 | authorize, 71 | findContactListById, 72 | instToJSONOrNotFound 73 | ), 74 | }) 75 | 76 | server.route({ 77 | method: "PUT", 78 | path: "/companies/{id}/contactLists/{listId}", 79 | config: { 80 | tags: ["api", "admin"], 81 | auth: { access: { scope: ["admin", "superadmin"] } }, 82 | validate: { 83 | params: { 84 | id: Joi.number().integer(), 85 | listId: Joi.number().integer(), 86 | }, 87 | payload: { 88 | description: Joi.string(), 89 | telephones: Joi.array().items(Joi.string().telephone()), 90 | emails: Joi.array().items(Joi.string().email()), 91 | }, 92 | }, 93 | }, 94 | handler: handleRequestWith( 95 | authorize, 96 | findContactListById, 97 | (list, request) => { 98 | return list.update(request.payload).then(list => list.toJSON()) 99 | } 100 | ), 101 | }) 102 | 103 | server.route({ 104 | method: "DELETE", 105 | path: "/companies/{id}/contactLists/{listId}", 106 | config: { 107 | tags: ["api", "admin"], 108 | auth: { access: { scope: ["admin", "superadmin"] } }, 109 | validate: { 110 | params: { 111 | id: Joi.number().integer(), 112 | listId: Joi.number().integer(), 113 | }, 114 | }, 115 | }, 116 | handler: handleRequestWith(authorize, findContactListById, deleteInst), 117 | }) 118 | 119 | server.route({ 120 | method: "POST", 121 | path: "/companies/{id}/contactLists", 122 | config: { 123 | tags: ["api", "admin"], 124 | auth: { access: { scope: ["admin", "superadmin"] } }, 125 | validate: { 126 | params: { 127 | id: Joi.number().integer(), 128 | listId: Joi.number().integer(), 129 | }, 130 | payload: { 131 | description: Joi.string(), 132 | telephones: Joi.array().items(Joi.string().telephone()), 133 | emails: Joi.array().items(Joi.string().email()), 134 | }, 135 | }, 136 | }, 137 | handler: handleRequestWith(authorize, request => { 138 | const m = getModels(request) 139 | request.payload.transportCompanyId = request.params.id 140 | return m.ContactList.create(request.payload).then(list => list.toJSON()) 141 | }), 142 | }) 143 | 144 | next() 145 | } 146 | register.attributes = { 147 | name: "endpoint-company-telephone-lists", 148 | } 149 | -------------------------------------------------------------------------------- /src/lib/endpoints/onemap.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import qs from "querystring" 3 | import _ from "lodash" 4 | import assert from "assert" 5 | import Boom from "boom" 6 | 7 | var lastToken = { 8 | exp: 0, 9 | token: null 10 | } 11 | 12 | export async function getToken () { 13 | var now = new Date().getTime() 14 | 15 | /* 10 minutes to expiry */ 16 | if (lastToken.exp * 1e3 - now > 10 * 60e3) { 17 | return lastToken.token 18 | } else { 19 | var url = 'https://developers.onemap.sg/publicapi/publicsessionid' 20 | 21 | var tokenRequest = axios.get(url).then((response) => { 22 | lastToken.exp = response.data.expiry_timestamp 23 | lastToken.token = response.data.access_token 24 | return response.data.access_token 25 | }) 26 | 27 | return tokenRequest 28 | } 29 | } 30 | 31 | export async function query (path, queryString) { 32 | var token = await getToken() 33 | 34 | var options = _.extend({}, queryString, { 35 | token: token 36 | }) 37 | 38 | var response = await axios.get(`https://developers.onemap.sg/publicapi/${path}?` + qs.stringify(options)) 39 | 40 | return response.data 41 | } 42 | 43 | export function register (server, options, next) { 44 | server.route({ 45 | path: "/onemap/{what}", 46 | method: "GET", 47 | async handler (request, reply) { 48 | try { 49 | var data = await query(request.params.what, request.query) 50 | 51 | reply(data) 52 | } catch (err) { 53 | console.error(err) 54 | reply(Boom.badImplementation(err)) 55 | } 56 | } 57 | }) 58 | next() 59 | } 60 | 61 | register.attributes = { 62 | name: "endpoint-onemap" 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/endpoints/stops.js: -------------------------------------------------------------------------------- 1 | const Joi = require("joi") 2 | 3 | const { 4 | handleRequestWith, 5 | authorizeByRole, 6 | instToJSONOrNotFound, 7 | deleteInst, 8 | } = require("../util/endpoints") 9 | 10 | export const register = function register(server, options, next) { 11 | if (!server.plugins["sequelize"]) { 12 | throw new Error("Sequelize has to be initialized first!") 13 | } 14 | const { models: m } = server.plugins["sequelize"] 15 | 16 | const authorize = authorizeByRole("manage-routes", () => undefined) 17 | const findById = request => m.Stop.findById(request.params.id) 18 | 19 | server.route({ 20 | method: "GET", 21 | path: "/stops", 22 | config: { 23 | auth: false, 24 | tags: ["api", "admin", "commuter"], 25 | }, 26 | handler: handleRequestWith( 27 | () => m.Stop.findAll(), 28 | stops => stops.map(s => s.toJSON()) 29 | ), 30 | }) 31 | 32 | server.route({ 33 | method: "GET", 34 | path: "/stops/{id}", 35 | config: { 36 | tags: ["api", "admin", "commuter"], 37 | auth: false, 38 | validate: { 39 | params: { 40 | id: Joi.number(), 41 | }, 42 | }, 43 | }, 44 | handler: handleRequestWith(findById, instToJSONOrNotFound), 45 | }) 46 | 47 | server.route({ 48 | method: "POST", 49 | path: "/stops", 50 | config: { 51 | tags: ["api", "admin"], 52 | auth: { 53 | access: { 54 | scope: ["superadmin"], 55 | }, 56 | }, 57 | validate: {}, 58 | }, 59 | handler: handleRequestWith(authorize, request => { 60 | delete request.payload.id 61 | return m.Stop.create(request.payload).then(s => s.toJSON()) 62 | }), 63 | }) 64 | 65 | server.route({ 66 | method: "PUT", 67 | path: "/stops/{id}", 68 | config: { 69 | tags: ["api", "admin"], 70 | auth: { 71 | access: { 72 | scope: ["superadmin"], 73 | }, 74 | }, 75 | validate: { 76 | params: { 77 | id: Joi.number().integer(), 78 | }, 79 | }, 80 | }, 81 | handler: handleRequestWith( 82 | authorize, 83 | findById, 84 | async (stopInst, request) => { 85 | if (stopInst) { 86 | delete request.payload.id 87 | await stopInst.update(request.payload) 88 | } 89 | return stopInst 90 | }, 91 | instToJSONOrNotFound 92 | ), 93 | }) 94 | 95 | server.route({ 96 | method: "DELETE", 97 | path: "/stops/{id}", 98 | config: { 99 | tags: ["api", "admin"], 100 | auth: { 101 | access: { scope: ["superadmin"] }, 102 | }, 103 | validate: { 104 | params: { 105 | id: Joi.number(), 106 | }, 107 | }, 108 | }, 109 | handler: handleRequestWith(authorize, findById, deleteInst), 110 | }) 111 | next() 112 | } 113 | register.attributes = { 114 | name: "endpoint-stops", 115 | } 116 | -------------------------------------------------------------------------------- /src/lib/endpoints/tripStatuses.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | import Joi from "joi" 3 | import Boom from "boom" 4 | import leftPad from "left-pad" 5 | import assert from "assert" 6 | 7 | import { getModels } from "../util/common" 8 | import * as events from "../events/events" 9 | 10 | const auth = require("../core/auth") 11 | 12 | /** 13 | * @param {Object} server - a HAPI server 14 | * @param {Object} options - unused for now 15 | * @param {Function} next - a callback to signal that the next middleware 16 | * should initialise 17 | */ 18 | export function register(server, options, next) { 19 | server.route({ 20 | method: "POST", 21 | path: "/trips/{id}/messages", 22 | config: { 23 | tags: ["api", "admin", "driver"], 24 | auth: { access: { scope: ["driver", "admin", "superadmin"] } }, 25 | description: "Posts messages to users interested in this trip", 26 | notes: ` 27 | This endpoint also allows callers to set trip status, and 28 | send a message to passengers, if the trip is cancelled 29 | `, 30 | validate: { 31 | payload: Joi.object({ 32 | message: Joi.string().allow(""), 33 | status: Joi.string(), 34 | }), 35 | query: { 36 | messagePassengers: Joi.boolean().optional(), 37 | }, 38 | }, 39 | }, 40 | // FIXME: for creator use the username or something 41 | handler: async function(request, reply) { 42 | try { 43 | let m = getModels(request) 44 | let creator = null 45 | let tripInst = await m.Trip.findById(request.params.id, { 46 | include: [ 47 | { 48 | model: m.TripStop, 49 | include: [m.Stop], 50 | }, 51 | m.Route, 52 | ], 53 | order: [[m.TripStop, "time"]], 54 | }) 55 | 56 | if (request.auth.credentials.scope === "admin") { 57 | creator = request.auth.credentials.adminId || "admin" 58 | await auth.assertAdminRole( 59 | request.auth.credentials, 60 | "update-trip-status", 61 | tripInst.route.transportCompanyId 62 | ) 63 | } else if (request.auth.credentials.scope === "driver") { 64 | let driverInst = await m.Driver.findById( 65 | request.auth.credentials.driverId 66 | ) 67 | 68 | creator = `Driver ${driverInst.id} (${driverInst.name})` 69 | 70 | assert.equal(tripInst.driverId, driverInst.id) 71 | } else if (request.auth.credentials.scope === "superadmin") { 72 | creator = `Beeline SuperAdmin ${request.auth.credentials.email}` 73 | } 74 | 75 | // Add a trip status 76 | const { message, status } = request.payload 77 | let data = { 78 | creator, 79 | time: new Date(), 80 | message, 81 | } 82 | 83 | let changes = typeof message === 'string' 84 | ? { status, messages: [data].concat(tripInst.messages) } 85 | : { status } 86 | 87 | await tripInst.update(changes) 88 | 89 | if (request.payload.status === "cancelled") { 90 | // Get the number of passengers -- mandatory for event 91 | const numPassengers = await m.Ticket.count({ 92 | where: { status: "valid" }, 93 | include: [ 94 | { 95 | model: m.TripStop, 96 | as: "boardStop", 97 | where: { tripId: tripInst.id }, 98 | }, 99 | ], 100 | }) 101 | const subscriptions = await m.EventSubscription.findAll({ 102 | raw: true, 103 | }) 104 | events.setSubscriptionList(subscriptions) 105 | events.emit("tripCancelled", { 106 | trip: _.assign(tripInst.toJSON(), { numPassengers }), 107 | }) 108 | } 109 | 110 | if ( 111 | request.query.messagePassengers && 112 | request.payload.status === "cancelled" 113 | ) { 114 | let route = tripInst.route 115 | let firstStop = tripInst.tripStops[0] 116 | let time = 117 | leftPad(firstStop.time.getHours(), 2, "0") + 118 | ":" + 119 | leftPad(firstStop.time.getMinutes(), 2, "0") 120 | 121 | let messageBody = 122 | `(DO NOT REPLY) Attention: The service for today ` + 123 | `has been cancelled due to unforeseen circumstances. Please make ` + 124 | `alternative transport arrangements. Today's fare will be refunded ` + 125 | `and we sincerely apologise for the inconvenience caused to all our commuters.` 126 | 127 | await tripInst.messagePassengers(messageBody, { 128 | sender: creator, 129 | ccDetail: `${route.label}: ${route.from} - ${route.to} @${time}`, 130 | }) 131 | } 132 | 133 | reply(data) 134 | } catch (err) { 135 | console.error(err.stack) 136 | reply(Boom.badImplementation(err.message)) 137 | } 138 | }, 139 | }) 140 | 141 | next() 142 | } 143 | register.attributes = { 144 | name: "endpoint-trip-statuses", 145 | } 146 | -------------------------------------------------------------------------------- /src/lib/events/events.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events') 2 | import eventDefinitions from './definitions' 3 | import eventFormatters from './formatters' 4 | import * as eventHandlers from './handlers' 5 | import Joi from 'joi' 6 | import assert from 'assert' 7 | import _ from 'lodash' 8 | 9 | export var defaultEmitter = new EventEmitter(eventDefinitions, eventFormatters).setMaxListeners(50) 10 | var activeSubscriptions = [] 11 | 12 | // Handle the secondary events 13 | defaultEmitter.on('newBooking', (data) => { 14 | var timeToTrip = data.trip.tripStops[0].time.getTime() - Date.now() 15 | 16 | defaultEmitter.emit('urgentBooking', _.assign({timeToTrip}, data)) 17 | }) 18 | defaultEmitter.on('error', (error) => { 19 | console.log(error.stack) 20 | }) 21 | 22 | /** If ever this function is made asynchronous, remember to mutually 23 | exclude multiple threads calling setSubscriptionList at the same time */ 24 | export function setSubscriptionList (list) { 25 | for (let [event, unbindFn] of activeSubscriptions) { 26 | unbindFn() 27 | } 28 | 29 | activeSubscriptions = [] 30 | 31 | for (let {event, params, agent, formatter, handler} of list) { 32 | let listener = (data) => { 33 | try { 34 | let formattedMessage = eventFormatters[event][formatter](data) 35 | 36 | assert(typeof (formattedMessage.message) === 'string', 37 | `Formatter '${formatter}' for event '${event}' must return an object with a 'message' property`) 38 | 39 | eventHandlers[handler](agent, formattedMessage) 40 | } catch (err) { 41 | console.log(`Error handling message for agent #${agent && agent.id} ${err && err.message}`) 42 | console.log(err.stack) 43 | } 44 | } 45 | 46 | try { 47 | let unbindFn = on(event, params, listener) 48 | activeSubscriptions.push([event, unbindFn]) 49 | } catch (err) { 50 | console.log(err.stack) 51 | } 52 | } 53 | } 54 | 55 | export function emit (event, data) { 56 | try { // handlers must not affect the execution of code 57 | var definition = eventDefinitions[event] 58 | 59 | assert(definition) 60 | Joi.assert(data, definition.schema) 61 | 62 | defaultEmitter.emit(event, data) 63 | } catch (err) { 64 | console.log(err.stack) 65 | } 66 | } 67 | 68 | /* 69 | on(event, [params], data) 70 | 71 | Overrides the on(event, callback) handler to accept 72 | a params option. The callback will only be called 73 | if the data passes the filters specified in params. 74 | 75 | Returns a method that, when called, removes the listener 76 | */ 77 | export function on () { 78 | assert(arguments.length === 2 || arguments.length === 3) 79 | var event, callback, params 80 | 81 | if (arguments.length === 2) { 82 | [event, callback] = arguments 83 | } else if (arguments.length === 3) { 84 | [event, params, callback] = arguments 85 | } 86 | 87 | var definition = eventDefinitions[event] 88 | 89 | assert(definition, `No such event type ${event}`) 90 | if (definition.params) { 91 | // Warn about any unused parameters 92 | let {error} = Joi.validate(params, definition.params) 93 | 94 | if (error) { 95 | console.error(`${event} Validation error ${JSON.stringify(error)}`) 96 | } 97 | 98 | // But when actually installing the handler, be more lenient 99 | let {value: validatedParamsUnk, error: errorUnk} = Joi.validate( 100 | params, 101 | definition.params.isJoi ? definition.params.unknown() : Joi.object(definition.params).unknown() 102 | ) 103 | 104 | assert(!errorUnk, `${event} Validation error ${JSON.stringify(errorUnk)}`) 105 | 106 | params = validatedParamsUnk 107 | } 108 | 109 | var realCallback = definition.filter 110 | ? (data) => { 111 | if (definition.filter(params, data)) { 112 | callback(data) 113 | } 114 | } : callback 115 | 116 | defaultEmitter.on(event, realCallback) 117 | 118 | return () => defaultEmitter.removeListener(event, realCallback) 119 | } 120 | -------------------------------------------------------------------------------- /src/lib/events/formatters.js: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import {formatDate} from '../util/common' 3 | 4 | export default { 5 | transactionFailure: { 6 | '0' (event) { 7 | return { 8 | message: `Transaction by user ${event.userId} failed with ${event.message}`, 9 | } 10 | } 11 | }, 12 | 13 | newBooking: { 14 | '0' (event) { 15 | return { 16 | message: `A new booking has been made on ${event.trip.route.label} ` + 17 | `${event.trip.route.from} to ${event.trip.route.to} (${formatDate(event.trip.date)})` 18 | } 19 | } 20 | }, 21 | 22 | lateArrival: { 23 | '0' (event) { 24 | return { 25 | message: `Bus arrived ${Math.floor(event.timeAfter / 60000)} mins late ${event.trip.route.label} ` + 26 | `${event.trip.route.from} to ${event.trip.route.to} (${formatDate(event.trip.date)})` 27 | } 28 | } 29 | }, 30 | 31 | lateETA: { 32 | '0' (event) { 33 | return { 34 | message: `Bus may be ${Math.floor(event.timeAfter / 60000)} mins late ${event.trip.route.label} ` + 35 | `${event.trip.route.from} to ${event.trip.route.to} (${formatDate(event.trip.date)})` 36 | } 37 | } 38 | }, 39 | 40 | urgentBooking: { 41 | '0' (event) { 42 | return { 43 | message: `A new booking has been made ${Math.floor(event.timeToTrip / 60000)} mins before ` + 44 | `the start of the trip ` + 45 | `${event.trip.route.label} ` + 46 | `${event.trip.route.from} to ${event.trip.route.to} ` + 47 | `(on ${formatDate(event.trip.date)})`, 48 | } 49 | } 50 | }, 51 | 52 | tripCancelled: { 53 | '0' (event) { 54 | return { 55 | message: `Trip has been cancelled. ${event.trip.route.label} ` + 56 | `${event.trip.route.from} to ${event.trip.route.to} (on ${formatDate(event.trip.date)})`, 57 | severity: 6, 58 | } 59 | } 60 | }, 61 | 62 | noPings: { 63 | '0' (event) { 64 | return { 65 | message: `Driver app was not switched on ` + 66 | `${Math.floor(event.minsBefore)} mins before start of ` + 67 | `${event.trip.route.label} ` + 68 | `${event.trip.route.from} to ${event.trip.route.to} (on ${formatDate(event.trip.date)})`, 69 | severity: (event.minsBefore <= 5) ? 5 : 4 70 | } 71 | } 72 | }, 73 | 74 | passengersMessaged: { 75 | '0' (event) { 76 | return { 77 | message: `"${event.message}". Sent by ${event.sender} to ` + 78 | `${event.trip.route.label} ` + 79 | `${event.trip.route.from} to ${event.trip.route.to} (on ${formatDate(event.trip.date)})`, 80 | severity: 4, 81 | } 82 | } 83 | }, 84 | 85 | internalServerError: { 86 | '0' (event) { 87 | return { 88 | severity: 4, 89 | message: `Internal server error: ${event.error.stack}` 90 | } 91 | } 92 | }, 93 | 94 | lifecycle: { 95 | '0' (event) { 96 | return { 97 | message: `Server ${event.stage}ed on host ${os.hostname()}` 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/lib/events/handlers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { sendSMS } from "../util/sms"; 4 | import { sendMail } from "../util/email"; 5 | import { formatTime24 } from "../util/common"; 6 | import { sendTelegram } from "../util/telegram"; 7 | import assert from "assert"; 8 | 9 | export function sms(agent, payload) { 10 | assert(agent.telephone); 11 | 12 | sendSMS({ 13 | to: agent.telephone, 14 | from: 15 | payload.severity && payload.severity >= 6 16 | ? "BeeEmergncy" 17 | : payload.severity && payload.severity >= 5 18 | ? "BeeCritical" 19 | : "BeelineOps", 20 | body: `${ 21 | payload.message 22 | }. Details @ https://monitoring.beeline.sg Sent: ${formatTime24( 23 | Date.now() 24 | )}` 25 | }); 26 | } 27 | 28 | export function email(agent, payload) { 29 | assert(agent.email); 30 | 31 | let criticality = 32 | payload.severity && payload.severity >= 6 33 | ? "EMERGNCY" 34 | : payload.severity && payload.severity >= 5 ? "CRITICAL" : "OPS"; 35 | 36 | sendMail({ 37 | from: "admin@beeline.sg", 38 | to: agent.email, 39 | subject: `[${criticality}] New Beeline Event`, 40 | text: payload.message 41 | }); 42 | } 43 | 44 | // Temporary hard-code on telegram bot 45 | export function telegram(agent, payload) { 46 | assert(agent.notes.telegramChatId); 47 | 48 | let criticality = 49 | payload.severity && payload.severity >= 6 50 | ? "EMERGNCY" 51 | : payload.severity && payload.severity >= 5 ? "CRITICAL" : "OPS"; 52 | 53 | const message = `[${criticality}] ${payload.message} Sent: ${formatTime24( 54 | Date.now() 55 | )}`; 56 | 57 | sendTelegram(agent.notes.telegramChatId, message); 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/models/Account.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | export default function (modelCache) { 3 | var DataTypes = modelCache.db.Sequelize 4 | return modelCache.db.define('account', { 5 | name: DataTypes.STRING 6 | }, 7 | { 8 | classMethods: { 9 | getByName (name, options) { 10 | return this.findOrCreate(_.assign({ 11 | where: { name }, 12 | defaults: { name }, 13 | }, options)) 14 | .then((inst_isCreated) => { 15 | return inst_isCreated[0] 16 | }) 17 | } 18 | } 19 | }) 20 | } 21 | 22 | export function makeAssociation (modelCache) { 23 | var Account = modelCache.require('Account') 24 | var TransactionItem = modelCache.require('TransactionItem') 25 | // not has One, e.g. cost of goods sold 26 | Account.hasMany(TransactionItem, { 27 | foreignKey: "itemId", 28 | constraints: false, 29 | scope: { 30 | itemType: "account" 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/models/Admin.js: -------------------------------------------------------------------------------- 1 | import * as auth from "../core/auth" 2 | 3 | export default function (modelCache) { 4 | var DataTypes = modelCache.db.Sequelize 5 | return modelCache.db.define('admin', { 6 | email: { 7 | type: DataTypes.STRING, 8 | allowNull: false 9 | }, 10 | /* Name provided by Gmail */ 11 | emailName: DataTypes.STRING, 12 | /* Name provided by creator */ 13 | name: DataTypes.STRING, 14 | receiveAlerts: DataTypes.BOOLEAN, 15 | telephone: DataTypes.STRING, 16 | notes: DataTypes.JSONB, 17 | }, { 18 | classMethods: { 19 | allFromCompany (id) { 20 | var companyIdList = [0] 21 | if (id) companyIdList.push(id) 22 | 23 | return this.findAll({ 24 | where: { 25 | transportCompanyId: { 26 | $in: companyIdList, 27 | }, 28 | }, 29 | }) 30 | }, 31 | allToAlertFromCompany (id) { 32 | var companyIdList = [0] 33 | if (id) companyIdList.push(id) 34 | 35 | return this.findAll({ 36 | include: [ 37 | { 38 | model: modelCache.models.TransportCompany, 39 | where: {id: {$in: companyIdList}}, 40 | } 41 | ], 42 | where: { 43 | receiveAlerts: true, 44 | telephone: {$ne: null}, 45 | } 46 | }) 47 | }, 48 | }, 49 | instanceMethods: { 50 | makeToken () { 51 | return auth.signSession({ 52 | email: this.email, 53 | app_metadata: { 54 | roles: ["admin"], 55 | lastUpdated: this.updatedAt.getTime() 56 | } 57 | }) 58 | }, 59 | } 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/models/AdminCompany.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('adminCompany', { 4 | adminId: { 5 | type: DataTypes.INTEGER, 6 | allowNull: false, 7 | }, 8 | transportCompanyId: { 9 | type: DataTypes.INTEGER, 10 | allowNull: false, 11 | }, 12 | name: DataTypes.TEXT, 13 | permissions: DataTypes.ARRAY(DataTypes.STRING), 14 | }, { 15 | indexes: [ 16 | {fields: ['adminId', 'transportCompanyId'], unique: true} 17 | ] 18 | }) 19 | } 20 | 21 | export function makeAssociation (modelCache) { 22 | modelCache.models.Admin.belongsToMany(modelCache.models.TransportCompany, { 23 | through: modelCache.models.AdminCompany 24 | }) 25 | modelCache.models.TransportCompany.belongsToMany(modelCache.models.Admin, { 26 | through: modelCache.models.AdminCompany 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/models/Alert.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('alert', { 4 | alertId: { 5 | type: DataTypes.TEXT, 6 | primaryKey: true, 7 | } 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/models/Asset.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | 4 | return modelCache.db.define('asset', { 5 | id: { 6 | type: DataTypes.STRING(50), 7 | primaryKey: true, 8 | }, 9 | data: DataTypes.TEXT, 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/models/Bid.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | import { 3 | InvalidArgumentError, 4 | TransactionError, 5 | ChargeError, 6 | } from "../util/errors" 7 | 8 | export default modelCache => { 9 | const VOID_STATUSES = ["void", "withdrawn", "failed"] 10 | const VALID_STATUSES = ["pending", "bidded"] 11 | const STATUSES = VOID_STATUSES.concat(VALID_STATUSES) 12 | 13 | let DataTypes = modelCache.db.Sequelize 14 | return modelCache.db.define( 15 | "bid", 16 | { 17 | userId: { 18 | type: DataTypes.INTEGER, 19 | allowNull: false, 20 | }, 21 | routeId: { 22 | type: DataTypes.INTEGER, 23 | allowNull: false, 24 | }, 25 | status: { 26 | type: DataTypes.STRING, 27 | validate: { 28 | isIn: { 29 | args: [STATUSES], 30 | msg: "Must be one of " + STATUSES, 31 | }, 32 | }, 33 | allowNull: false, 34 | }, 35 | price: { 36 | type: DataTypes.DECIMAL(10, 2), // eslint-disable-line new-cap 37 | allowNull: false, 38 | }, 39 | priceF: { 40 | type: DataTypes.VIRTUAL, 41 | set: function(val) { 42 | this.setDataValue("price", val) 43 | }, 44 | get: function() { 45 | const v = this.getDataValue("price") 46 | return v == null ? null : parseFloat(v) 47 | }, 48 | }, 49 | notes: DataTypes.JSONB, 50 | }, 51 | { 52 | indexes: [{ fields: ["userId"] }, { fields: ["routeId"] }], 53 | 54 | classMethods: { 55 | async createForUserAndRoute(userInst, routeInst, price, options = {}) { 56 | const m = modelCache.models 57 | 58 | // User must first have saved his card details 59 | ChargeError.assert( 60 | userInst.savedPaymentInfo && 61 | userInst.savedPaymentInfo.default_source, 62 | "You need to provide payment information." 63 | ) 64 | const { tags } = routeInst 65 | TransactionError.assert( 66 | tags.includes("crowdstart") || tags.includes("crowdstart-private"), 67 | "Selected route is not a crowdstart route" 68 | ) 69 | 70 | const crowdstartExpiry = _.get(routeInst, "notes.crowdstartExpiry") 71 | const isOpenForBids = 72 | !crowdstartExpiry || 73 | Date.now() < new Date(crowdstartExpiry).getTime() 74 | 75 | TransactionError.assert( 76 | isOpenForBids, 77 | "Selected route is no longer open for bidding" 78 | ) 79 | 80 | const existingBid = await m.Bid.findOne({ 81 | where: { 82 | routeId: routeInst.id, 83 | userId: userInst.id, 84 | status: "bidded", 85 | }, 86 | ...options, 87 | }) 88 | 89 | InvalidArgumentError.assert( 90 | !existingBid, 91 | "A bid has already been made for this route" 92 | ) 93 | 94 | const bid = await m.Bid.create( 95 | { 96 | routeId: routeInst.id, 97 | userId: userInst.id, 98 | price: price, 99 | status: "bidded", 100 | }, 101 | { ...options } 102 | ) 103 | 104 | return bid 105 | }, 106 | }, 107 | } 108 | ) 109 | } 110 | 111 | export const makeAssociation = function makeAssociation(modelCache) { 112 | const Bid = modelCache.require("Bid") 113 | const Route = modelCache.require("Route") 114 | const User = modelCache.require("User") 115 | 116 | Bid.belongsTo(Route, { 117 | foreignKey: "routeId", 118 | }) 119 | Bid.belongsTo(User, { 120 | foreignKey: "userId", 121 | }) 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/models/ContactList.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('contactList', { 4 | transportCompanyId: { 5 | type: DataTypes.INTEGER, 6 | allowNull: false, 7 | }, 8 | description: DataTypes.STRING, 9 | telephones: { 10 | type: DataTypes.ARRAY(DataTypes.STRING), 11 | allowNull: false, 12 | }, 13 | emails: { 14 | type: DataTypes.ARRAY(DataTypes.STRING), 15 | allowNull: false, 16 | }, 17 | }, 18 | { 19 | indexes: [ 20 | { fields: ["transportCompanyId"] } 21 | ] 22 | }) 23 | } 24 | 25 | export function makeAssociation (modelCache) { 26 | var TransportCompany = modelCache.require('TransportCompany') 27 | var ContactList = modelCache.require('ContactList') 28 | 29 | ContactList.belongsTo(TransportCompany, { 30 | foreignKey: "transportCompanyId" 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/models/Discount.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('discount', { 4 | description: DataTypes.STRING, 5 | code: DataTypes.STRING, 6 | userOptions: DataTypes.JSONB, 7 | discountAmounts: DataTypes.JSONB, 8 | refundAmounts: DataTypes.JSONB, 9 | promotionId: DataTypes.INTEGER, 10 | params: DataTypes.JSONB, 11 | }, { 12 | indexes: [ 13 | {fields: ['code']} 14 | ] 15 | }) 16 | } 17 | 18 | export function makeAssociation (modelCache) { 19 | var Discount = modelCache.require('Discount') 20 | var TransactionItem = modelCache.require('TransactionItem') 21 | 22 | Discount.hasOne(TransactionItem, { 23 | foreignKey: "itemId", 24 | constraints: false, 25 | scope: { 26 | itemType: "discount" 27 | } 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/models/Driver.js: -------------------------------------------------------------------------------- 1 | const ssaclAttributeRoles = require('ssacl-attribute-roles') 2 | import * as auth from '../core/auth' 3 | 4 | export default function (modelCache) { 5 | // FIXME: Node 5.12 why is this not being loaded? 6 | const ssaclAttributeRoles = require('ssacl-attribute-roles') 7 | 8 | var DataTypes = modelCache.db.Sequelize 9 | var model = modelCache.db.define('driver', { 10 | name: { 11 | type: DataTypes.STRING, 12 | allowNull: false 13 | }, 14 | telephone: { 15 | type: DataTypes.STRING, 16 | allowNull: false, 17 | unique: true, 18 | }, 19 | pairingCode: { 20 | type: DataTypes.STRING, 21 | roles: false, 22 | }, 23 | passwordHash: { 24 | type: DataTypes.STRING, 25 | roles: false, 26 | }, 27 | authKey: { 28 | type: DataTypes.STRING, 29 | roles: false, 30 | }, 31 | lastComms: DataTypes.DATE, 32 | }, 33 | { 34 | instanceMethods: 35 | { 36 | makeToken () { 37 | return auth.signSession({ 38 | role: "driver", 39 | driverId: this.id, 40 | lastUpdated: this.updatedAt.getTime() 41 | }) 42 | }, 43 | } 44 | }) 45 | 46 | ssaclAttributeRoles(model) 47 | return model 48 | } 49 | 50 | export function makeAssociation (modelCache) { 51 | var Driver = modelCache.require('Driver') 52 | var Vehicle = modelCache.require('Vehicle') 53 | var TransportCompany = modelCache.require('TransportCompany') 54 | var DriverCompany = modelCache.require('DriverCompany') 55 | Driver.hasMany(Vehicle, { 56 | foreignKey: "driverId" 57 | }) 58 | Driver.belongsToMany(TransportCompany, { 59 | through: DriverCompany, 60 | foreignKey: "driverId" 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/models/DriverCompany.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('driverCompany', { 4 | driverId: { 5 | type: DataTypes.INTEGER, 6 | allowNull: false, 7 | }, 8 | transportCompanyId: { 9 | type: DataTypes.INTEGER, 10 | allowNull: false, 11 | }, 12 | // provided by company 13 | name: DataTypes.TEXT, 14 | remarks: DataTypes.TEXT, 15 | }, { 16 | indexes: [ 17 | {fields: ['driverId', 'transportCompanyId'], unique: true} 18 | ] 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/models/EventSubscription.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | 3 | export default function (modelCache) { 4 | var DataTypes = modelCache.db.Sequelize 5 | return modelCache.db.define('eventSubscription', { 6 | event: DataTypes.STRING, 7 | transportCompanyId: DataTypes.INTEGER, 8 | agent: DataTypes.JSONB, 9 | formatter: DataTypes.STRING, 10 | handler: DataTypes.STRING, 11 | params: DataTypes.JSONB, 12 | }) 13 | } 14 | 15 | export function makeAssociation (modelCache) { 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/models/Payment.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('payment', { 4 | paymentResource: DataTypes.STRING, 5 | incoming: DataTypes.DECIMAL(10, 2), 6 | /* Store credit/debit in the same column, but in opposite sign */ 7 | outgoing: { 8 | type: DataTypes.VIRTUAL, 9 | set: function (val) { 10 | this.setDataValue("incoming", modelCache.neg(val)) 11 | }, 12 | get: function () { 13 | return modelCache.neg(this.getDataValue("incoming")) 14 | } 15 | }, 16 | data: DataTypes.JSON, 17 | options: DataTypes.JSONB, 18 | }) 19 | } 20 | 21 | export function makeAssociation (modelCache) { 22 | var Payment = modelCache.require('Payment') 23 | var TransactionItem = modelCache.require('TransactionItem') 24 | // user pay to Beeline 25 | Payment.hasOne(TransactionItem, { 26 | foreignKey: "itemId", 27 | constraints: false, 28 | scope: { 29 | itemType: "payment" 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/models/PromoUsage.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import _ from 'lodash' 3 | import Joi from "joi" 4 | 5 | export default function (modelCache) { 6 | var DataTypes = modelCache.db.Sequelize 7 | 8 | return modelCache.db.define('promoUsage', { 9 | userId: { type: DataTypes.INTEGER, allowNull: true }, 10 | promoId: { type: DataTypes.INTEGER }, 11 | count: { type: DataTypes.INTEGER }, 12 | }, { 13 | indexes: [ 14 | { fields: ['userId', 'promoId'], unique: true} 15 | ], 16 | 17 | classMethods: { 18 | // get number of tickets a promotion has been applied to across users 19 | // creates an entry if it doesn't exist 20 | // @param: promoId - id of promotion, from promotion table 21 | // @returns: integer - sum of all uses of a promo by users 22 | getGlobalPromoUsage (promoId, options) { 23 | return this.findOrCreate(_.defaults({ 24 | defaults: { promoId, userId: null, count: 0 }, 25 | where: { promoId, userId: null } 26 | }, options)).then(([inst, isCreated]) => inst.count) 27 | }, 28 | // get number of tickets a promotion has been applied to for a user 29 | // creates an entry if it doesn't exist 30 | // @param: promoId - id of promotion, from promotion table 31 | // @param: userId - id of user, from user table 32 | // @returns: integer - count of times a promotion has been applied to tickets purchased by user 33 | getUserPromoUsage (promoId, userId, options) { 34 | return this.findOrCreate(_.defaults({ 35 | defaults: { promoId, userId, count: 0 }, 36 | where: { promoId, userId } 37 | }, options)).then(([inst, isCreated]) => inst.count) 38 | }, 39 | // called when a transaction is completed, 40 | // increments count based on how many tickets a promotion is applied to in a purchase 41 | // creates an entry if it doesn't exist 42 | // @param: promoId - id of promotion, from promotion table 43 | // @param: userId - id of user, from user table 44 | // @params: amount - INTEGER: amount to increment by 45 | addUserPromoUsage (promoId, userId, amount, options) { 46 | assert(isFinite(promoId)) 47 | assert(isFinite(userId)) 48 | assert(isFinite(amount)) 49 | 50 | return this.findOrCreate(_.defaults({ 51 | defaults: { promoId, userId, count: 0 }, 52 | where: { userId, promoId }, 53 | }, options)).then(([inst, isCreated]) => { 54 | assert(amount >= 0) 55 | return inst.increment('count', _.defaults({ by: amount }, options)) 56 | }) 57 | }, 58 | // for refund - reverse increment 59 | subtractUserPromoUsage (promoId, userId, amount, options) { 60 | assert(isFinite(promoId)) 61 | assert(isFinite(userId)) 62 | assert(isFinite(amount)) 63 | 64 | return this.find({ where: { userId, promoId }}, options) 65 | .then(inst => { 66 | assert(inst.count >= amount) 67 | assert(amount >= 0) 68 | return inst.decrement('count', _.defaults({by: amount}, options)) 69 | }) 70 | }, 71 | // Updates total counter when a purchase is complete 72 | async addGlobalPromoUsage (promoId, amount, options) { 73 | assert(isFinite(promoId)) 74 | assert(isFinite(amount)) 75 | 76 | return this.findOrCreate(_.defaults({ 77 | defaults: { promoId, userId: null, count: 0 }, 78 | where: { promoId, userId: null } 79 | }, options)).then(([inst, isCreated]) => { 80 | assert(amount >= 0) 81 | return inst.increment('count', _.defaults({by: amount}, options)) 82 | }) 83 | }, 84 | 85 | async subtractGlobalPromoUsage (promoId, amount, options) { 86 | assert(isFinite(promoId)) 87 | assert(isFinite(amount)) 88 | 89 | return this.find({ where: { promoId, userId: null }}, options) 90 | .then((inst) => { 91 | assert(inst.count >= amount) 92 | assert(amount >= 0) 93 | return inst.decrement('count', _.defaults({by: amount}, options)) 94 | }) 95 | }, 96 | } 97 | }) 98 | } 99 | 100 | export function makeAssociation (modelCache) { 101 | var PromoUsage = modelCache.require('PromoUsage') 102 | var Promotion = modelCache.require('Promotion') 103 | var User = modelCache.require('User') 104 | 105 | PromoUsage.belongsTo(User, { 106 | foreignKey: "userId" 107 | }) 108 | 109 | PromoUsage.belongsTo(Promotion, { 110 | foreignKey: "promoId" 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /src/lib/models/Promotion.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('promotion', { 4 | code: DataTypes.TEXT, 5 | type: DataTypes.TEXT, 6 | params: DataTypes.JSONB, 7 | description: DataTypes.TEXT 8 | }, { 9 | indexes: [ 10 | {fields: ['code']} 11 | ] 12 | }) 13 | } 14 | 15 | export function makeAssociation (modelCache) { 16 | var Promotion = modelCache.require('Promotion') 17 | var PromoUsage = modelCache.require('PromoUsage') 18 | 19 | Promotion.hasMany(PromoUsage, { 20 | foreignKey: "promoId" 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/models/RefundPayment.js: -------------------------------------------------------------------------------- 1 | export default modelCache => { 2 | const DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define("refundPayment", { 4 | paymentResource: DataTypes.STRING, 5 | incoming: DataTypes.DECIMAL(10, 2), // eslint-disable-line new-cap 6 | /* Store credit/debit in the same column, but in opposite sign */ 7 | outgoing: { 8 | type: DataTypes.VIRTUAL, 9 | set: function(val) { 10 | this.setDataValue("incoming", modelCache.neg(val)) 11 | }, 12 | get: function() { 13 | return modelCache.neg(this.getDataValue("incoming")) 14 | }, 15 | }, 16 | data: DataTypes.JSON, 17 | }) 18 | } 19 | 20 | export const makeAssociation = function makeAssociation(modelCache) { 21 | const RefundPayment = modelCache.require("RefundPayment") 22 | const TransactionItem = modelCache.require("TransactionItem") 23 | RefundPayment.hasOne(TransactionItem, { 24 | foreignKey: "itemId", 25 | constraints: false, 26 | scope: { 27 | itemType: "refundPayment", 28 | }, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/models/Route.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | 3 | export default modelCache => { 4 | let DataTypes = modelCache.db.Sequelize 5 | return modelCache.db.define( 6 | "route", 7 | { 8 | name: DataTypes.STRING, 9 | from: DataTypes.STRING, 10 | to: DataTypes.STRING, 11 | path: DataTypes.JSON, 12 | transportCompanyId: DataTypes.INTEGER, 13 | label: DataTypes.STRING, 14 | schedule: DataTypes.STRING, 15 | tags: DataTypes.ARRAY(DataTypes.STRING), 16 | companyTags: DataTypes.ARRAY(DataTypes.STRING), 17 | notes: DataTypes.JSONB, 18 | features: DataTypes.TEXT, 19 | }, 20 | { 21 | defaultScope: { 22 | attributes: { 23 | exclude: ["features"], 24 | }, 25 | }, 26 | } 27 | ) 28 | } 29 | 30 | export const makeAssociation = function(modelCache) { 31 | let Route = modelCache.require("Route") 32 | let Trip = modelCache.require("Trip") 33 | let RouteAnnouncement = modelCache.require("RouteAnnouncement") 34 | let IndicativeTrip = modelCache.require("IndicativeTrip") 35 | Route.hasMany(Trip, { 36 | foreignKey: "routeId", 37 | }) 38 | Route.hasMany(RouteAnnouncement, { 39 | foreignKey: "routeId", 40 | }) 41 | Route.hasOne(IndicativeTrip, { 42 | foreignKey: "routeId", 43 | }) 44 | Route.belongsTo(modelCache.models.TransportCompany, { 45 | foreignKey: "transportCompanyId", 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/models/RouteAnnouncement.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('routeAnnouncement', { 4 | validFrom: DataTypes.DATE, 5 | validTo: DataTypes.DATE, 6 | routeId: { 7 | type: DataTypes.INTEGER, 8 | }, 9 | title: DataTypes.STRING, 10 | message: DataTypes.STRING 11 | }, { 12 | indexes: [ 13 | {fields: ["routeId"]} 14 | ] 15 | }) 16 | } 17 | 18 | export function makeAssociation (modelCache) { 19 | var RouteAnnouncement = modelCache.require('RouteAnnouncement') 20 | var Route = modelCache.require('Route') 21 | RouteAnnouncement.belongsTo(Route, { 22 | foreignKey: "routeId" 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/models/RouteCredit.js: -------------------------------------------------------------------------------- 1 | import * as auth from "../core/auth" 2 | import assert from 'assert' 3 | import _ from 'lodash' 4 | 5 | export default function (modelCache) { 6 | var DataTypes = modelCache.db.Sequelize 7 | var db = modelCache.db 8 | 9 | return modelCache.db.define('routeCredit', { 10 | companyId: { 11 | type: DataTypes.INTEGER, 12 | allowNull: false, 13 | }, 14 | userId: { 15 | type: DataTypes.INTEGER, 16 | }, 17 | tag: { 18 | type: DataTypes.STRING, 19 | allowNull: false, 20 | }, 21 | balance: { 22 | type: DataTypes.DECIMAL(10, 2), 23 | allowNull: false, 24 | defaultValue: '0.00', 25 | } 26 | }, 27 | { 28 | indexes: [ 29 | {fields: ['userId', 'companyId', 'tag'], unique: true}, 30 | {fields: ['companyId', 'userId']} 31 | ], 32 | classMethods: { 33 | get (userId, companyId, tag, options) { 34 | assert(userId && tag && companyId) 35 | return this.findOrCreate(_.defaults({ 36 | where: {userId, tag, companyId}, 37 | defaults: {userId, tag, companyId, balance: 0} 38 | }, options)) 39 | .then(([inst, isCreated]) => inst) 40 | }, 41 | /* Doesn't create the entry unless it is necessary */ 42 | getUserCredits (userId, companyId, tag, options) { 43 | assert(userId && tag && companyId) 44 | return this.findOne(_.defaults({ 45 | where: {userId, tag, companyId}, 46 | }, options)) 47 | .then(inst => inst ? inst.balance : '0.00') 48 | }, 49 | addUserCredits (userId, companyId, tag, amount, options) { 50 | assert(isFinite(amount)) 51 | 52 | return this.get(userId, companyId, tag, options) 53 | .then(inst => { 54 | assert(parseFloat(inst.balance) >= -parseFloat(amount)) 55 | 56 | return inst.increment('balance', _.defaults({by: amount}, options)) 57 | .then(() => inst) 58 | }) 59 | }, 60 | subtractUserCredits (userId, companyId, tag, amount, options) { 61 | assert(isFinite(amount)) 62 | 63 | return this.get(userId, companyId, tag, options) 64 | .then(inst => { 65 | assert(parseFloat(inst.balance) + 0.0001 >= parseFloat(amount)) 66 | return inst.decrement('balance', _.defaults({by: amount}, options)) 67 | }) 68 | }, 69 | }, 70 | }) 71 | } 72 | 73 | export function makeAssociation (modelCache) { 74 | var User = modelCache.require('User') 75 | var RouteCredit = modelCache.require('RouteCredit') 76 | var TransportCompany = modelCache.require('TransportCompany') 77 | var TransactionItem = modelCache.require('TransactionItem') 78 | RouteCredit.belongsTo(User, { 79 | foreignKey: "userId" 80 | }) 81 | RouteCredit.belongsTo(TransportCompany, { 82 | foreignKey: "companyId" 83 | }) 84 | RouteCredit.hasMany(TransactionItem, { 85 | foreignKey: "itemId", 86 | constraints: false, 87 | scope: { 88 | itemType: { 89 | $in: ["routeCredits"] 90 | } 91 | } 92 | }) 93 | } 94 | -------------------------------------------------------------------------------- /src/lib/models/RoutePass.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | 3 | export default function (modelCache) { 4 | const STATUSES = ["failed", "valid", "void", "refunded", "pending", "expired"] 5 | 6 | var DataTypes = modelCache.db.Sequelize 7 | return modelCache.db.define('routePass', { 8 | companyId: { 9 | type: DataTypes.INTEGER, 10 | allowNull: false, 11 | }, 12 | userId: { 13 | type: DataTypes.INTEGER, 14 | }, 15 | tag: { 16 | type: DataTypes.STRING, 17 | allowNull: false, 18 | }, 19 | status: { 20 | type: DataTypes.STRING, 21 | validate: { 22 | isIn: { 23 | args: [STATUSES], 24 | msg: 'Must be one of ' + STATUSES.join(',') 25 | } 26 | }, 27 | allowNull: false, 28 | }, 29 | expiresAt: DataTypes.DATEONLY, 30 | notes: DataTypes.JSONB 31 | }, { 32 | hooks: { 33 | beforeCreate: function (routePass, options) { 34 | if (!routePass.expiresAt) { 35 | const validPeriod = (process.env.VALID_DAYS || 35) * 24 * 3600 * 1000 36 | routePass.expiresAt = new Date(new Date().getTime() + validPeriod) 37 | } 38 | }, 39 | }, 40 | indexes: [ 41 | {fields: ["userId", "status"]}, 42 | {fields: ["companyId", "tag"]}, 43 | ], 44 | classMethods: { 45 | async refundFromTicket (tb, previousTransactionItem, companyId, userId, tag) { 46 | const {models, transaction} = tb 47 | 48 | assert(previousTransactionItem.itemType === 'ticketSale', 49 | `Trying to refund route credits from an item of type ${previousTransactionItem.itemType}`) 50 | 51 | const amountToRefund = Math.abs(previousTransactionItem.debit) 52 | assert(typeof (amountToRefund) === 'number' && isFinite(amountToRefund), 53 | `The previous transaction specified an incorrect amount to refund`) 54 | 55 | const notes = { price: amountToRefund, refundedTicketId: previousTransactionItem.itemId } 56 | const routePass = await models.RoutePass.create( 57 | {userId, companyId, tag, status: 'valid', notes}, 58 | {transaction} 59 | ) 60 | 61 | tb.undoFunctions.push( 62 | (t) => routePass.update({status: 'failed'}, {transaction: t}) 63 | ) 64 | 65 | tb.transactionItemsByType.routePass = tb.transactionItemsByType.routePass || [] 66 | tb.transactionItemsByType.routePass.push({ 67 | itemType: 'routePass', 68 | itemId: routePass.id, 69 | credit: amountToRefund 70 | }) 71 | 72 | let cogsAcc = await models.Account.getByName( 73 | 'Cost of Goods Sold', {transaction} 74 | ) 75 | 76 | let upstreamCreditsAcc = await models.Account.getByName( 77 | 'Upstream Refunds', {transaction} 78 | ) 79 | 80 | tb.transactionItemsByType.account = tb.transactionItemsByType.account || [] 81 | tb.transactionItemsByType.account.push({ 82 | itemType: 'account', 83 | itemId: upstreamCreditsAcc.id, 84 | credit: amountToRefund, 85 | notes: { transportCompanyId: companyId } 86 | }) 87 | tb.transactionItemsByType.account.push({ 88 | itemType: 'account', 89 | itemId: cogsAcc.id, 90 | debit: amountToRefund, 91 | notes: null 92 | }) 93 | 94 | return tb 95 | } 96 | } 97 | }) 98 | } 99 | 100 | export function makeAssociation (modelCache) { 101 | var User = modelCache.require('User') 102 | var RoutePass = modelCache.require('RoutePass') 103 | var TransportCompany = modelCache.require('TransportCompany') 104 | var TransactionItem = modelCache.require('TransactionItem') 105 | RoutePass.belongsTo(User, { 106 | foreignKey: "userId" 107 | }) 108 | RoutePass.belongsTo(TransportCompany, { 109 | foreignKey: "companyId" 110 | }) 111 | RoutePass.hasMany(TransactionItem, { 112 | foreignKey: "itemId", 113 | constraints: false, 114 | scope: { 115 | itemType: { 116 | $in: ["routePass"] 117 | } 118 | } 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/models/Stop.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('stop', { 4 | description: DataTypes.STRING, 5 | road: DataTypes.STRING, 6 | label: DataTypes.STRING, 7 | postcode: DataTypes.STRING, 8 | type: DataTypes.STRING, 9 | coordinates: DataTypes.GEOMETRY("POINT"), 10 | viewUrl: DataTypes.STRING, 11 | }) 12 | } 13 | 14 | export var postSync = [ 15 | ` 16 | CREATE INDEX stop_svy_index ON stops 17 | USING GIST ( 18 | ST_Transform(ST_SetSRID(coordinates, 4326), 3414)); 19 | ` 20 | ] 21 | -------------------------------------------------------------------------------- /src/lib/models/Subscription.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('subscription', { 4 | // userId and routeId combined has to be unique 5 | userId: {type: DataTypes.INTEGER}, 6 | routeLabel: {type: DataTypes.STRING}, 7 | status: { 8 | type: DataTypes.STRING, 9 | allowNull: false, 10 | validate: { 11 | isIn: [["valid", "invalid"]] 12 | } 13 | } 14 | }, 15 | { 16 | indexes: [ 17 | {fields: ["userId", "routeLabel"], unique: true}, 18 | {fields: ["routeLabel"]} 19 | ] 20 | }) 21 | } 22 | 23 | export function makeAssociation (modelCache) { 24 | var Subscription = modelCache.require('Subscription') 25 | var User = modelCache.require('User') 26 | Subscription.belongsTo(User, { 27 | foreignKey: "userId" 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/models/SuggestedRoute.js: -------------------------------------------------------------------------------- 1 | import Joi from "joi" 2 | 3 | export const stopsSchema = Joi.array() 4 | .items( 5 | Joi.object({ 6 | lat: Joi.number(), 7 | lng: Joi.number(), 8 | numBoard: Joi.number() 9 | .integer() 10 | .min(0), 11 | numAlight: Joi.number() 12 | .integer() 13 | .min(0), 14 | time: Joi.number().integer(), 15 | stopId: Joi.number().integer(), 16 | description: Joi.string(), 17 | pathToNext: Joi.string().allow(null), // encoded polyline string 18 | }).unknown() 19 | ) 20 | .min(2) 21 | 22 | export const routeSchema = Joi.alternatives().try( 23 | Joi.object({ 24 | status: Joi.allow("Success"), 25 | stops: stopsSchema, 26 | }), 27 | Joi.object({ 28 | status: Joi.allow("Failure"), 29 | reason: Joi.string(), 30 | }) 31 | ) 32 | 33 | /** 34 | * The Suggestion data model 35 | * @param {ModelCache} modelCache 36 | * @return {Model} 37 | */ 38 | export default function(modelCache) { 39 | const DataTypes = modelCache.db.Sequelize 40 | return modelCache.db.define("suggestedRoute", { 41 | seedSuggestionId: DataTypes.INTEGER, 42 | userId: DataTypes.INTEGER, 43 | routeId: DataTypes.INTEGER, 44 | adminEmail: DataTypes.STRING, 45 | route: { 46 | type: DataTypes.JSONB, 47 | set(val) { 48 | Joi.assert(val, routeSchema) 49 | this.setDataValue("route", val) 50 | }, 51 | }, 52 | }) 53 | } 54 | 55 | /** 56 | * 57 | * @param {*} modelCache 58 | * @return {void} 59 | */ 60 | export function makeAssociation(modelCache) { 61 | let Suggestion = modelCache.require("Suggestion") 62 | let SuggestedRoute = modelCache.require("SuggestedRoute") 63 | let Route = modelCache.require("Route") 64 | 65 | SuggestedRoute.belongsTo(Suggestion, { 66 | foreignKey: "seedSuggestionId", 67 | as: "seedSuggestion", 68 | }) 69 | SuggestedRoute.belongsTo(Route, { 70 | foreignKey: "routeId", 71 | as: "crowdstartRoute", 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/models/Ticket.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | import Sequelize from "sequelize" 3 | import {TransactionError} from '../transactions' 4 | 5 | export const STATUSES = ['failed', 'valid', 'void', 'refunded', 'pending'] 6 | 7 | export default function (modelCache) { 8 | const VOID_STATUSES = ['void', 'refunded', 'failed'] 9 | const VALID_STATUSES = _.difference(STATUSES, VOID_STATUSES) 10 | 11 | const updateAvailability = op => async (tickets, options) => { 12 | const TripStop = modelCache.require('TripStop') 13 | const Trip = modelCache.require('Trip') 14 | if (tickets.length === 0) { 15 | return 16 | } 17 | const boardStops = await TripStop.findAll({ 18 | where: { id: { $in: _.uniq(tickets.map(t => t.boardStopId)) } }, 19 | attributes: ['id', 'tripId'], 20 | transaction: options.transaction, 21 | }) 22 | const boardStopById = _.keyBy(boardStops, 'id') 23 | const tripIds = tickets.map(t => boardStopById[t.boardStopId].tripId) 24 | 25 | return _.forEach(_.countBy(tripIds), async (seatsBooked, tripId) => { 26 | await Trip.update( 27 | {seatsAvailable: Sequelize.literal('"seatsAvailable" ' + op + ' ' + seatsBooked)}, 28 | { 29 | where: { id: tripId }, 30 | transaction: options.transaction, 31 | } 32 | ) 33 | }) 34 | } 35 | 36 | const beforeBulkCreate = updateAvailability('-') 37 | 38 | const statusChanged = (from, to, ticket) => 39 | _.includes(from, ticket._previousDataValues.status) && 40 | _.includes(to, ticket.get('status')) 41 | const becameVoid = ticket => statusChanged(VALID_STATUSES, VOID_STATUSES, ticket) 42 | const becameValid = ticket => statusChanged(VOID_STATUSES, VALID_STATUSES, ticket) 43 | 44 | const afterUpdate = (ticket, options) => { 45 | if (becameVoid(ticket)) { 46 | return updateAvailability('+')([ticket], options) 47 | } else if (becameValid(ticket)) { 48 | return updateAvailability('-')([ticket], options) 49 | } 50 | } 51 | 52 | var DataTypes = modelCache.db.Sequelize 53 | return modelCache.db.define('ticket', { 54 | userId: { 55 | type: DataTypes.INTEGER, 56 | allowNull: false, 57 | }, 58 | alightStopId: { 59 | type: DataTypes.INTEGER, 60 | allowNull: false, 61 | }, 62 | boardStopId: { 63 | type: DataTypes.INTEGER, 64 | allowNull: false, 65 | }, 66 | status: { 67 | type: DataTypes.STRING, 68 | validate: { 69 | isIn: { 70 | args: [STATUSES], 71 | msg: 'Must be a F/V/V/R/P' 72 | } 73 | }, 74 | allowNull: false, 75 | }, 76 | notes: DataTypes.JSONB 77 | }, { 78 | hooks: { 79 | beforeCreate: (ticket, options) => beforeBulkCreate([ticket], options), 80 | beforeBulkCreate, 81 | afterUpdate, 82 | afterBulkUpdate: (t, o) => { 83 | throw new TransactionError( 84 | 'Bulk updates not supported, individual updates necessary to maintain seat availability' 85 | ) 86 | }, 87 | }, 88 | indexes: [ 89 | {fields: ["boardStopId", "status"]}, 90 | {fields: ["alightStopId"]} 91 | ] 92 | }) 93 | } 94 | 95 | export function makeAssociation (modelCache) { 96 | var Ticket = modelCache.require('Ticket') 97 | var TripStop = modelCache.require('TripStop') 98 | var TransactionItem = modelCache.require('TransactionItem') 99 | var User = modelCache.require('User') 100 | 101 | Ticket.belongsTo(TripStop, { 102 | foreignKey: "boardStopId", 103 | as: "boardStop", 104 | onDelete: 'NO ACTION', 105 | }) 106 | 107 | Ticket.belongsTo(TripStop, { 108 | foreignKey: "alightStopId", 109 | as: "alightStop", 110 | }) 111 | Ticket.hasMany(TransactionItem, { 112 | foreignKey: "itemId", 113 | constraints: false, 114 | scope: { 115 | itemType: { 116 | $in: ["ticketRefund", "ticketExpense", "ticketSale"] 117 | } 118 | } 119 | }) 120 | Ticket.belongsTo(User, { 121 | foreignKey: "userId" 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /src/lib/models/Transaction.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('transaction', { 4 | committed: { 5 | type: DataTypes.BOOLEAN, 6 | allowNull: false, 7 | }, 8 | description: DataTypes.TEXT, 9 | 10 | creatorType: { 11 | type: DataTypes.STRING, 12 | validate: { 13 | isIn: { 14 | args: [[ 15 | "system", 16 | "admin", 17 | "user", 18 | "superadmin", 19 | ]], 20 | msg: 'Unknown creatorType' 21 | } 22 | }, 23 | allowNull: true, 24 | }, 25 | creatorId: DataTypes.STRING, 26 | type: { 27 | type: DataTypes.STRING, 28 | validate: { 29 | isIn: { 30 | args: [[ 31 | "ticketPurchase", 32 | "conversion", 33 | "refund", 34 | "referralRewards", 35 | "routeCreditPurchase", 36 | "routeCreditExpiry", 37 | "freeRouteCredit", 38 | "refundToRouteCredit", 39 | "routePassPurchase", 40 | "routePassExpiry", 41 | "freeRoutePass", 42 | "refundToRoutePass", 43 | "refundPayment" 44 | ]], 45 | msg: "Unknown type" 46 | } 47 | }, 48 | allowNull: true 49 | } 50 | }, 51 | { 52 | // when create a transaction object also include below models 53 | classMethods: { 54 | allTransactionTypes () { 55 | return [{ 56 | model: modelCache.models.TransactionItem, 57 | include: [ 58 | {model: modelCache.models.RefundPayment, as: "refundPayment"}, 59 | {model: modelCache.models.Payment, as: "payment"}, 60 | {model: modelCache.models.Discount, as: "discount"}, 61 | {model: modelCache.models.Account, as: "account"}, 62 | {model: modelCache.models.Transfer, as: "transfer"}, 63 | {model: modelCache.models.Ticket, as: "ticketSale"}, 64 | {model: modelCache.models.Ticket, as: "ticketExpense"}, 65 | {model: modelCache.models.Ticket, as: "ticketRefund"} 66 | ] 67 | }] 68 | } 69 | } 70 | }) 71 | } 72 | 73 | export function makeAssociation (modelCache) { 74 | var Transaction = modelCache.require('Transaction') 75 | var TransactionItem = modelCache.require('TransactionItem') 76 | /* Define the various transaction types */ 77 | Transaction.hasMany(TransactionItem, { 78 | foreignKey: "transactionId", 79 | onDelete: "CASCADE" 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/models/Transfer.js: -------------------------------------------------------------------------------- 1 | // log purpose 2 | export default function (modelCache) { 3 | var DataTypes = modelCache.db.Sequelize 4 | return modelCache.db.define('transfer', { 5 | /* Either company id OR thirdParty must be 6 | used */ 7 | transportCompanyId: { 8 | type: DataTypes.INTEGER, 9 | allowNull: true 10 | }, 11 | thirdParty: DataTypes.STRING, 12 | token: DataTypes.STRING, 13 | 14 | incoming: DataTypes.DECIMAL(10, 2), 15 | /* Store credit/debit in the same column, but in opposite sign */ 16 | outgoing: { 17 | type: DataTypes.VIRTUAL, 18 | set: function (val) { 19 | this.setDataValue("incoming", modelCache.neg(val)) 20 | }, 21 | get: function () { 22 | return modelCache.neg(this.getDataValue("incoming")) 23 | } 24 | } 25 | }, 26 | { 27 | indexes: [ 28 | {fields: ["transportCompanyId"]} /* Necessary for reverse lookup */ 29 | ] 30 | }) 31 | } 32 | 33 | export function makeAssociation (modelCache) { 34 | var Transfer = modelCache.require('Transfer') 35 | var TransactionItem = modelCache.require('TransactionItem') 36 | Transfer.hasOne(TransactionItem, { 37 | // transfer id as itemId 38 | foreignKey: "itemId", 39 | constraints: false, 40 | scope: { 41 | itemType: "transfer" 42 | } 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/models/TransportCompany.js: -------------------------------------------------------------------------------- 1 | import ssaclAttributeRoles from "ssacl-attribute-roles" 2 | 3 | /** 4 | * Returns the model for a TransportCompany 5 | * @param {object} modelCache 6 | * @return {Model} 7 | */ 8 | export default function(modelCache) { 9 | let DataTypes = modelCache.db.Sequelize 10 | let Company = modelCache.db.define( 11 | "transportCompany", 12 | { 13 | type: DataTypes.INTEGER, 14 | logo: DataTypes.BLOB, 15 | name: DataTypes.STRING(50), // eslint-disable-line 16 | email: DataTypes.STRING(50), // eslint-disable-line 17 | contactNo: DataTypes.STRING(50), // eslint-disable-line 18 | smsOpCode: { 19 | type: DataTypes.STRING(11), // eslint-disable-line 20 | allowNull: true, 21 | }, 22 | features: DataTypes.TEXT, 23 | terms: DataTypes.TEXT, 24 | clientId: { 25 | type: DataTypes.STRING, 26 | roles: false, 27 | }, 28 | clientSecret: { 29 | type: DataTypes.STRING, 30 | roles: false, 31 | }, 32 | sandboxId: { 33 | type: DataTypes.STRING, 34 | roles: false, 35 | }, 36 | sandboxSecret: { 37 | type: DataTypes.STRING, 38 | roles: false, 39 | }, 40 | /* We don't leak the client id to users of the API, so we 41 | leak the presence of it instead */ 42 | hasClientId: { 43 | type: DataTypes.VIRTUAL, 44 | get() { 45 | return !!this.getDataValue("clientId") 46 | }, 47 | }, 48 | referrer: DataTypes.STRING, 49 | status: DataTypes.STRING, 50 | }, 51 | { 52 | defaultScope: { 53 | attributes: { exclude: ["logo", "features", "terms"] }, // exclude by default the heavy attributes 54 | }, 55 | } 56 | ) 57 | 58 | ssaclAttributeRoles(Company) 59 | return Company 60 | } 61 | 62 | /** 63 | * 64 | * @param {object} modelCache 65 | */ 66 | export function makeAssociation(modelCache) { 67 | let Driver = modelCache.require("Driver") 68 | let TransportCompany = modelCache.require("TransportCompany") 69 | let DriverCompany = modelCache.require("DriverCompany") 70 | let ContactList = modelCache.require("ContactList") 71 | 72 | TransportCompany.belongsToMany(Driver, { 73 | through: DriverCompany, 74 | foreignKey: "transportCompanyId", 75 | }) 76 | 77 | TransportCompany.hasMany(ContactList, { 78 | foreignKey: "transportCompanyId", 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /src/lib/models/TripStop.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('tripStop', { 4 | tripId: { 5 | type: DataTypes.INTEGER, 6 | }, 7 | stopId: { 8 | type: DataTypes.INTEGER, 9 | }, 10 | canBoard: DataTypes.BOOLEAN, 11 | canAlight: DataTypes.BOOLEAN, 12 | time: DataTypes.DATE 13 | }, 14 | { 15 | indexes: [ 16 | {fields: ["tripId"]}, 17 | {fields: ["stopId"]}, 18 | {fields: ["time", "tripId"]} 19 | ] 20 | }) 21 | } 22 | 23 | export function makeAssociation (modelCache) { 24 | var Trip = modelCache.require('Trip') 25 | var Stop = modelCache.require('Stop') 26 | var TripStop = modelCache.require('TripStop') 27 | var Ticket = modelCache.require('Ticket') 28 | TripStop.belongsTo(Stop, { 29 | foreignKey: "stopId" 30 | }) 31 | TripStop.belongsTo(Trip, { 32 | foreignKey: "tripId" 33 | }) 34 | TripStop.hasMany(Ticket, { 35 | foreignKey: "boardStopId", 36 | onDelete: 'NO ACTION', 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/models/UserSuggestedRoute.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('userSuggestedRoute', { 4 | email: DataTypes.STRING, 5 | name: DataTypes.STRING, 6 | path: DataTypes.GEOMETRY("LINESTRING"), 7 | }) 8 | } 9 | 10 | export function makeAssociation (modelCache) { 11 | modelCache.models.UserSuggestedRouteStop.belongsTo(modelCache.models.UserSuggestedRoute, { 12 | foreignKey: "userSuggestedRouteId", 13 | }) 14 | modelCache.models.UserSuggestedRoute.hasMany(modelCache.models.UserSuggestedRouteStop, { 15 | foreignKey: "userSuggestedRouteId", 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/models/UserSuggestedRouteStop.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('userSuggestedRouteStop', { 4 | userSuggestedRouteId: DataTypes.INTEGER, 5 | arriveAt: DataTypes.INTEGER, 6 | stopId: DataTypes.INTEGER, 7 | }) 8 | } 9 | 10 | export function makeAssociation (modelCache) { 11 | modelCache.models.UserSuggestedRouteStop.belongsTo(modelCache.models.Stop, { 12 | foreignKey: "stopId", 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/models/Vehicle.js: -------------------------------------------------------------------------------- 1 | export default function (modelCache) { 2 | var DataTypes = modelCache.db.Sequelize 3 | return modelCache.db.define('vehicle', { 4 | vehicleNumber: DataTypes.STRING(10), 5 | driverId: DataTypes.INTEGER, 6 | photo: DataTypes.BLOB 7 | }, 8 | { 9 | indexes: [ 10 | {fields: ["driverId"]} 11 | ] 12 | }) 13 | } 14 | 15 | export function makeAssociation (modelCache) { 16 | var Driver = modelCache.require('Driver') 17 | var Vehicle = modelCache.require('Vehicle') 18 | Vehicle.belongsTo(Driver, { 19 | foreignKey: "driverId" 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/promotions/Promotion.js: -------------------------------------------------------------------------------- 1 | import * as ticketDiscountQualifiers from './functions/ticketDiscountQualifiers' 2 | import {discountingFunctions, refundingFunctions} from './functions/discountRefunds' 3 | import Joi from 'joi' 4 | import assert from 'assert' 5 | 6 | export class Promotion { 7 | 8 | /** 9 | * @param params -- the parameters from the database 10 | * @param connection -- an object exposing {db, models, transaction, dryRUn}, 11 | * used by the class to get access to the database connection. 12 | * @param items -- the items generated by the PO, decorated with additional 13 | * information, such as trip and price data 14 | * @param options -- the options provided by the user 15 | * @param description -- a human description of the promotion 16 | * @param promotionId -- the id for the Sequelize model of the promotion 17 | * @param qualifiers -- a set of functions that validate eligibility 18 | */ 19 | constructor (transactionBuilder, params, options, description, promotionId, qualifiers = ticketDiscountQualifiers) { 20 | this.params = params 21 | this.options = options 22 | this.connection = transactionBuilder.connection 23 | this.items = transactionBuilder.items 24 | this.transactionBuilder = transactionBuilder 25 | this.isInitialized = false 26 | this.description = description 27 | this.promotionId = promotionId 28 | this.qualifiers = qualifiers 29 | } 30 | 31 | /** 32 | * Default filter Function. Filters trips by the criteria given in 33 | * this.params.qualifyingCriteria. 34 | * Overriding implementations should set `this._filteredItems` to 35 | * the list of items, and then they should set this.isInitialized to true 36 | */ 37 | async initialize () { 38 | const {qualifyingFunctions} = this.qualifiers 39 | var {qualifyingCriteria} = this.params 40 | var {items, options} = this 41 | 42 | // Ensure that the params meet the schema 43 | Joi.assert(qualifyingCriteria, Joi.array().min(1).items( 44 | Joi.object().keys({ 45 | type: Joi.string().valid(Object.keys(qualifyingFunctions)), 46 | params: Joi.object() 47 | }) 48 | )) 49 | 50 | this._filteredItems = await this.params.qualifyingCriteria 51 | .reduce(async (filteredPromise, criterion) => { 52 | const filtered = await filteredPromise 53 | const rule = qualifyingFunctions[criterion.type](criterion.params, this.transactionBuilder) 54 | return rule(filtered, options) 55 | }, Promise.resolve(items.filter(it => !it.transactionItem || it.transactionItem.notes.outstanding > 0))) 56 | 57 | this.isInitialized = true 58 | } 59 | 60 | isQualified (options) { 61 | assert(this.isInitialized, "Promotions must be initialized before use") 62 | return this._filteredItems && this._filteredItems.length > 0 63 | } 64 | 65 | getValidItems () { 66 | return this._filteredItems 67 | } 68 | 69 | // exclude tickets already paid for (routePass - outstanding = 0) from further discounts 70 | // apply usage limit to filtered items - userLimit 71 | removePaidAndApplyLimits (limit = null) { 72 | let removedPaid = this._filteredItems.filter(fi => !fi.transactionItem || fi.transactionItem.notes.outstanding > 0) 73 | 74 | return this._filteredItems = removedPaid.slice(0, limit || removedPaid.length) 75 | } 76 | 77 | /** 78 | * Default discounting method. Applies discounts by the conditions 79 | * specified in discountFunction 80 | */ 81 | computeDiscountsAndRefunds () { 82 | const {discountFunction, refundFunction} = this.params 83 | const items = this._filteredItems 84 | const options = this.options 85 | 86 | Joi.assert(discountFunction, Joi.object().keys({ 87 | type: Joi.string().valid(Object.keys(discountingFunctions)), 88 | params: Joi.object() 89 | })) 90 | const discount = discountingFunctions[discountFunction.type](discountFunction.params) 91 | const discounts = discount(items, options) 92 | const refunds = refundingFunctions[refundFunction.type](items, options, discounts) 93 | 94 | return [discounts, refunds] 95 | } 96 | 97 | /** 98 | * This method is called from within a transaction after the trips 99 | * have been filtered, but before the discounts have been computed. 100 | * 101 | * e.g. here you can pull the amount of credits available for the user 102 | */ 103 | async preApplyHooks () {} 104 | 105 | /** 106 | * This method is called from within a transaction after the discounts 107 | * have been computed. 108 | * 109 | * e.g. here you can reduce the users credits by the amount applied. 110 | */ 111 | async postApplyHooks () {} 112 | 113 | /** 114 | * This method is called from within a transaction after the discounts 115 | * have been computed, and the transaction is *committed* 116 | * 117 | * e.g. here you can reduce the users credits by the amount applied. 118 | */ 119 | async commitHooks () {} 120 | 121 | /** 122 | @param options 123 | @prop transaction -- The transaction that is part of the undo 124 | */ 125 | async undoHooks (options) {} 126 | } 127 | -------------------------------------------------------------------------------- /src/lib/promotions/RoutePass.js: -------------------------------------------------------------------------------- 1 | import {Promotion} from './Promotion' 2 | import assert from 'assert' 3 | import _ from 'lodash' 4 | 5 | /** 6 | * Extends the default Promotion by mandating the limitByRouteTags qualifying criteria 7 | */ 8 | export class RoutePass extends Promotion { 9 | constructor (transactionBuilder, params, options, description, promotionId, qualifiers) { 10 | assert.strictEqual(typeof params.tag, 'string', 'RoutePass requires a "tag" param') 11 | assert(params.tag, 'RoutePass promos must be associated with a tag') 12 | 13 | // Add a limit by route tags criterion 14 | var newParams = _.cloneDeep(params) 15 | newParams.qualifyingCriteria.push({ 16 | type: 'limitByRouteTags', 17 | params: { 18 | tags: [params.tag] 19 | } 20 | }) 21 | 22 | super(transactionBuilder, newParams, options, description, promotionId, qualifiers) 23 | } 24 | 25 | async preApplyHooks () { 26 | assert(!this.transactionBuilder.transactionItemsByType.ticketSale) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/promotions/functions/routePassDiscountQualifiers.js: -------------------------------------------------------------------------------- 1 | import Joi from 'joi' 2 | import assert from 'assert' 3 | import _ from 'lodash' 4 | 5 | /** 6 | * All qualifying functions are generator functions that 7 | * takes in a param object and return a qualifying function that 8 | * acts on the list of items from purchaseOrder.getItems(), 9 | * filtering it based on selected criteria 10 | */ 11 | export const qualifyingFunctions = { 12 | /** 13 | * Limit to routes by one company 14 | * @param {number} companyId 15 | */ 16 | limitByCompany: function (params) { 17 | Joi.assert(params, { 18 | companyId: Joi.number().integer().required() 19 | }) 20 | 21 | return (items, options) => { 22 | return items.filter(item => item.companyId === params.companyId) 23 | } 24 | }, 25 | 26 | /** 27 | * Limit to certain routes 28 | * @param {number[]} routeIds 29 | */ 30 | limitByRouteTags: function (params) { 31 | Joi.assert(params, { 32 | tags: Joi.array().min(1).items(Joi.string()).required() 33 | }) 34 | 35 | return (items, options) => { 36 | return items.filter(item => { 37 | var tags = item.tags 38 | var intersection = _.intersection(tags, params.tags) 39 | 40 | return _.size(_.uniq(intersection)) === params.tags.length 41 | }) 42 | } 43 | }, 44 | 45 | /** 46 | * For limited period discounts 47 | * based on booking date 48 | * @param {date} startDate 49 | * @param {date} endDate 50 | * booking date to be provided as a @param {date} now on the options object 51 | */ 52 | limitByPurchaseDate: function (params) { 53 | const validatedParams = Joi.attempt(params, { 54 | startDate: Joi.date().required(), 55 | endDate: Joi.date().required() 56 | }) 57 | 58 | return (items, options) => { 59 | const now = new Date() 60 | const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) 61 | if ( 62 | (today < validatedParams.startDate.getTime()) || 63 | (today > validatedParams.endDate.getTime()) 64 | ) return [] 65 | return items 66 | } 67 | }, 68 | 69 | limitByContactList (params, transactionBuilder) { 70 | const {error, value: validatedParams} = Joi.validate(params, { 71 | contactListId: Joi.number().integer().required() 72 | }) 73 | 74 | assert(!error) 75 | 76 | return async (items, options) => { 77 | const contactList = await transactionBuilder.models.ContactList 78 | .findById(validatedParams.contactListId, {transaction: transactionBuilder.transaction}) 79 | 80 | const telephoneListKeyed = _.keyBy(contactList.telephones) 81 | const emailListKeyed = _.keyBy(contactList.emails) 82 | 83 | const users = (await transactionBuilder.models.User 84 | .findAll({ 85 | where: {id: {$in: items.map(i => i.userId)}}, 86 | transaction: transactionBuilder.transaction, 87 | attributes: ['id', 'telephone', 'email', 'emailVerified'] 88 | })) 89 | 90 | const recognized = users 91 | .filter(u => 92 | (u.telephone && u.telephone in telephoneListKeyed) || 93 | (u.email && u.emailVerified && u.email in emailListKeyed) 94 | ) 95 | .map(u => u.id) 96 | 97 | return items.filter(item => recognized.includes(item.userId)) 98 | } 99 | }, 100 | 101 | /** 102 | * N tickets for the price of M route passes 103 | * tickets are sorted by their prices from cheapest to most expensive 104 | * cheaper route passes are discounted first 105 | * @param {number} n 106 | * @param {number} m 107 | */ 108 | n4m (params) { 109 | Joi.assert(params, { 110 | n: Joi.number().integer().min(1).required(), 111 | m: Joi.number().integer().min(1).required() 112 | }) 113 | assert(params.n > params.m) 114 | const {n, m} = params 115 | 116 | return (items, options) => { 117 | const setsOfN = Math.floor(items.length / n) 118 | const leftovers = items.length - n * setsOfN 119 | const count = setsOfN * (n - m) + Math.max(leftovers - m, 0) 120 | const sorted = _.sortBy(items, item => parseFloat(item.balance)) 121 | return sorted.slice(0, count) 122 | } 123 | }, 124 | 125 | /** 126 | * For testing purpose 127 | * permits all ticket to pass through (i.e. discount applied for all route passes) 128 | */ 129 | noLimit: function (params) { 130 | return (items, options) => items 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/lib/routes.js: -------------------------------------------------------------------------------- 1 | module.exports = (server, options, next) => { 2 | server 3 | .register([ 4 | require("./core/auth"), 5 | require("./core/download"), 6 | require("./core/version"), 7 | require("./core/sequelize"), 8 | require("./endpoints/assets"), 9 | require("./endpoints/companies"), 10 | require("./endpoints/companyPromos"), 11 | require("./endpoints/companyContactLists"), 12 | require("./endpoints/drivers"), 13 | require("./endpoints/onemap"), 14 | require("./endpoints/eventSubscriptions"), 15 | require("./endpoints/liteRoutes"), 16 | require("./endpoints/routes"), 17 | require("./endpoints/stops"), 18 | require("./endpoints/suggestedRoutes"), 19 | require("./endpoints/suggestions"), 20 | require("./endpoints/suggestionsWeb"), 21 | require("./endpoints/tickets"), 22 | require("./endpoints/transactions"), 23 | require("./endpoints/transactionItems"), 24 | require("./endpoints/trips"), 25 | require("./endpoints/tripStatuses"), 26 | require("./endpoints/users"), 27 | require("./endpoints/userPaymentInfo"), 28 | require("./endpoints/admins"), 29 | require("./endpoints/vehicles"), 30 | require("./endpoints/routePassAdmin"), 31 | require("./endpoints/crowdstart"), 32 | require("./custom/wrs"), 33 | require("./custom/userSuggestedRoutes"), 34 | require("./daemons/eventSubscriptions"), 35 | ]) 36 | .then(next, err => { 37 | console.warn(err) 38 | next(err) 39 | }) 40 | } 41 | 42 | module.exports.attributes = { 43 | name: "setup", 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/util/analytics.js: -------------------------------------------------------------------------------- 1 | const analyticsPlugin = { 2 | register: function (server, options, next) { 3 | server.ext({ 4 | type: 'onPostAuth', 5 | method: async function (request, reply) { 6 | if (request.path === '/routes/recent') { 7 | try { 8 | var m = server.plugins['sequelize'].models 9 | // to save last used app name 10 | if (request.auth.credentials.userId) { 11 | let userInst = await m.User.findById(request.auth.credentials.userId) 12 | if (userInst) { 13 | if (request.headers["beeline-app-name"]) { 14 | await userInst.update({ 15 | lastUsedAppName: request.headers["beeline-app-name"] 16 | }) 17 | } 18 | } 19 | } 20 | } catch (error) { 21 | console.log("ERROR") 22 | console.log(error) 23 | } 24 | } 25 | return reply.continue() 26 | } 27 | }) 28 | next() 29 | } 30 | } 31 | 32 | analyticsPlugin.register.attributes = { 33 | name: 'analyticsPlugin', 34 | version: '1.0.0' 35 | } 36 | 37 | module.exports = analyticsPlugin 38 | -------------------------------------------------------------------------------- /src/lib/util/email.js: -------------------------------------------------------------------------------- 1 | import SMTPConnection from "smtp-connection" 2 | import nodemailer from "nodemailer" 3 | import BlueBird from "bluebird" 4 | 5 | export const send = async function send(envelope, message) { 6 | if ( 7 | !( 8 | process.env.SMTP_HOST && 9 | process.env.SMTP_PORT && 10 | process.env.SMTP_USER && 11 | process.env.SMTP_PASSWORD 12 | ) 13 | ) { 14 | throw new Error( 15 | "SMTP server settings are not set in environment variables! Please set " + 16 | "SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD" 17 | ) 18 | } 19 | 20 | let connection = new SMTPConnection({ 21 | port: process.env.SMTP_PORT, 22 | host: process.env.SMTP_HOST, 23 | secure: !!parseInt(process.env.SMTP_SECURE), 24 | debug: true, 25 | authMethod: "PLAIN", 26 | }) 27 | connection.on("error", err => console.error(err)) 28 | await BlueBird.promisify(connection.connect, { context: connection })() 29 | await BlueBird.promisify(connection.login, { context: connection })({ 30 | user: process.env.SMTP_USER, 31 | pass: process.env.SMTP_PASSWORD, 32 | }) 33 | let result = await BlueBird.promisify(connection.send, { 34 | context: connection, 35 | })(envelope, message) 36 | 37 | connection.quit() 38 | return result 39 | } 40 | 41 | // Nodemailer support 42 | let transporter = nodemailer.createTransport( 43 | `smtps://${encodeURIComponent(process.env.SMTP_USER)}:${encodeURIComponent( 44 | process.env.SMTP_PASSWORD 45 | )}@${process.env.SMTP_HOST}` 46 | ) 47 | 48 | export const sendMail = async function sendMail(options) { 49 | return transporter.sendMail(options) 50 | } 51 | 52 | /** 53 | * Replaces part of a string with asterisks 54 | * @param {string} s - the input string 55 | * @return {string} the string masked with asterisks. 56 | * only the first character is exposed if the input string length 57 | * is less than or equal to 4, otherwise, only the first two and 58 | * last two characters are exposed 59 | */ 60 | function hidePart(s) { 61 | if (s.length <= 4) { 62 | let r = s.substr(0, 1) 63 | while (r.length < s.length) { 64 | r += "*" 65 | } 66 | return r 67 | } else { 68 | let r1 = s.substr(0, 2) 69 | let r2 = s.substr(s.length - 2, 2) 70 | while (r1.length < s.length - r2.length) { 71 | r1 += "*" 72 | } 73 | r1 += r2 74 | return r1 75 | } 76 | } 77 | 78 | /** 79 | * Replaces the username and domain name section of an 80 | * email address with asterisks 81 | * @param {string} email - the email as a string 82 | * @return {string} the masked email 83 | */ 84 | export function anonymizeEmail(email) { 85 | if (email === null) { 86 | return null 87 | } else { 88 | let re = /^(.*)@(.*)\.([^.]+)$/ 89 | let matches = email.match(re) 90 | if (matches === null) { 91 | return null 92 | } 93 | 94 | let hiddenEmail = 95 | hidePart(matches[1]) + 96 | "@" + 97 | hidePart(matches[2]) + 98 | "." + 99 | hidePart(matches[3]) 100 | 101 | return hiddenEmail 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/lib/util/errors.js: -------------------------------------------------------------------------------- 1 | 2 | function makeErrorClass (name) { 3 | var constructor = function (message, data) { 4 | // Error.apply(this, [message]); 5 | this.message = message 6 | this.name = name 7 | this.stack = (new Error(message)).stack 8 | this.data = data 9 | } 10 | 11 | constructor.prototype = Object.create(Error.prototype) 12 | constructor.prototype.constructor = constructor 13 | 14 | constructor.assert = function (condition, message, data) { 15 | if (!condition) { 16 | throw new constructor(message || `Expected false == true`, data) 17 | } 18 | } 19 | constructor.assert.strictEqual = function (v1, v2, message, data) { 20 | if (v1 !== v2) { 21 | throw new constructor(message || `Expected ${v1} === ${v2}`, data) 22 | } 23 | } 24 | constructor.assert.strictNotEqual = function (v1, v2, message, data) { 25 | if (v1 === v2) { 26 | throw new constructor(message || `Expected ${v1} !== ${v2}`, data) 27 | } 28 | } 29 | 30 | return constructor 31 | } 32 | 33 | export var SecurityError = makeErrorClass('SecurityError') 34 | export var TransactionError = makeErrorClass('TransactionError') 35 | export var ChargeError = makeErrorClass('ChargeError') 36 | export var RateLimitError = makeErrorClass('RateLimitError') 37 | export var NotFoundError = makeErrorClass('NotFoundError') 38 | export var InvalidArgumentError = makeErrorClass('InvalidArgumentError') 39 | -------------------------------------------------------------------------------- /src/lib/util/image.js: -------------------------------------------------------------------------------- 1 | // https://en.wikipedia.org/wiki/List_of_file_signatures 2 | const KNOWN_IMAGE_MAGIC_BYTES = [ 3 | { 4 | bytes: [0xff, 0xd8, 0xff, 0xdb], 5 | mime: "image/jpeg", 6 | }, 7 | { 8 | bytes: [ 9 | 0xff, 10 | 0xd8, 11 | 0xff, 12 | 0xe0, 13 | 0x00, 14 | 0x10, 15 | 0x4a, 16 | 0x46, 17 | 0x49, 18 | 0x46, 19 | 0x00, 20 | 0x01, 21 | ], 22 | mime: "image/jpeg", 23 | }, 24 | { 25 | bytes: [0xff, 0xd8, 0xff, 0xe1, -1, -1, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 26 | mime: "image/jpeg", 27 | }, 28 | { 29 | bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 30 | mime: "image/png", 31 | }, 32 | ] 33 | 34 | /** 35 | * Scan through our list of magic bytes, and returns the corresponding image MIME. 36 | * 37 | * @param {Buffer} buffer 38 | * @return {string} 39 | */ 40 | export function imageMimeForMagicBytes(buffer) { 41 | for (let { bytes, mime } of KNOWN_IMAGE_MAGIC_BYTES) { 42 | if (bytes.every((byte, index) => byte === -1 || buffer[index] === byte)) { 43 | return mime 44 | } 45 | } 46 | return null 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/util/joi.js: -------------------------------------------------------------------------------- 1 | import Joi from "joi" 2 | 3 | module.exports = Joi.extend([ 4 | { 5 | base: Joi.string(), 6 | name: "string", 7 | language: { 8 | telephone: "A valid Singapore phone number", 9 | }, 10 | rules: [ 11 | { 12 | name: "telephone", 13 | validate(params, value, state, options) { 14 | let noSpaces = value.replace(/\s/g, "") 15 | 16 | const SingaporeNumber = /^(\+65)?([0-9]{8})$/ 17 | const match = noSpaces.match(SingaporeNumber) 18 | 19 | if (!match) { 20 | return this.createError( 21 | "string.telephone", 22 | { v: value }, 23 | state, 24 | options 25 | ) 26 | } else { 27 | return "+65" + match[2] 28 | } 29 | }, 30 | }, 31 | ], 32 | }, 33 | { 34 | base: Joi.object(), 35 | name: "latlng", 36 | language: { 37 | latlng: "needs to be a GeoJSON point, or LatLng object", 38 | latRange: "Latitude must be between {{min}} and {{max}}", 39 | lngRange: "Longitude must be between {{min}} and {{max}}", 40 | }, 41 | pre(value, state, options) { 42 | const tryLatLng = Joi.validate(value, { 43 | lat: Joi.number(), 44 | lng: Joi.number(), 45 | }) 46 | 47 | if (!tryLatLng.error) { 48 | return { 49 | type: "POINT", 50 | coordinates: [tryLatLng.value.lng, tryLatLng.value.lat], 51 | } 52 | } 53 | 54 | const tryGeoJson = Joi.validate(value, { 55 | type: Joi.string().valid("Point", "POINT"), 56 | coordinates: Joi.array() 57 | .items(Joi.number()) 58 | .length(2), 59 | }) 60 | 61 | if (!tryGeoJson.error) { 62 | return { type: "POINT", coordinates: tryGeoJson.value.coordinates } 63 | } 64 | 65 | // return this.createError("latlng", { v: value }, state, options) 66 | return tryGeoJson.error 67 | }, 68 | rules: [ 69 | { 70 | name: "latRange", 71 | params: { 72 | range: Joi.array() 73 | .items( 74 | Joi.number() 75 | .min(-90) 76 | .max(90) 77 | ) 78 | .length(2), 79 | }, 80 | validate(params, value, state, options) { 81 | if ( 82 | params.range[0] <= value.coordinates[1] && 83 | value.coordinates[1] <= params.range[1] 84 | ) { 85 | return value 86 | } 87 | return this.createError( 88 | "latlng.latRange", 89 | { v: value, min: params.range[0], max: params.range[1] }, 90 | state, 91 | options 92 | ) 93 | }, 94 | }, 95 | { 96 | name: "lngRange", 97 | params: { 98 | range: Joi.array() 99 | .items( 100 | Joi.number() 101 | .min(-180) 102 | .max(180) 103 | ) 104 | .length(2), 105 | }, 106 | validate(params, value, state, options) { 107 | if ( 108 | params.range[0] <= value.coordinates[0] && 109 | value.coordinates[0] <= params.range[1] 110 | ) { 111 | return value 112 | } 113 | return this.createError( 114 | "latlng.lngRange", 115 | { v: value, min: params.range[0], max: params.range[1] }, 116 | state, 117 | options 118 | ) 119 | }, 120 | }, 121 | ], 122 | }, 123 | ]) 124 | -------------------------------------------------------------------------------- /src/lib/util/onesignal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Note: there is no official OneSignal library for Node.js 3 | * 4 | * There are 3rd party libraries, but I'd rather not rely on them 5 | */ 6 | 7 | import axios from 'axios' 8 | import assert from 'assert' 9 | import querystring from 'querystring' 10 | 11 | function defaultOptions () { 12 | assert(process.env.ONESIGNAL_API_KEY) 13 | assert(process.env.ONESIGNAL_APP_ID) 14 | 15 | return { 16 | headers: { 17 | 'Content-Type': 'application/json; charset=utf-8', 18 | 'Authorization': `Basic ${process.env.ONESIGNAL_API_KEY}` 19 | } 20 | } 21 | } 22 | 23 | export function createNotification (contents) { 24 | return axios.post( 25 | 'https://onesignal.com/api/v1/notifications', 26 | { 27 | app_id: process.env.ONESIGNAL_APP_ID, 28 | ...contents 29 | }, 30 | defaultOptions() 31 | ) 32 | .then(r => r.data) 33 | } 34 | 35 | /** 36 | * Used only for testing, to verify that `createNotification` has 37 | * sent out a message 38 | * @param {*} id 39 | */ 40 | export function viewNotification (id) { 41 | return axios.get( 42 | `https://onesignal.com/api/v1/notifications/${id}?` + querystring.stringify({ 43 | app_id: process.env.ONESIGNAL_APP_ID 44 | }), 45 | defaultOptions() 46 | ) 47 | .then(r => r.data) 48 | } 49 | 50 | /** 51 | * Used only for testing, to verify that `createNotification` has 52 | * sent out a message 53 | * @param {*} id 54 | */ 55 | export function cancelNotification (id) { 56 | return axios.delete( 57 | `https://onesignal.com/api/v1/notifications/${id}?` + querystring.stringify({ 58 | app_id: process.env.ONESIGNAL_APP_ID 59 | }), 60 | defaultOptions() 61 | ) 62 | .then(r => r.data) 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/util/sms.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import assert from 'assert' 3 | import BlueBird from 'bluebird' 4 | 5 | export var AccountSID = process.env.TWILIO_ACCOUNT_SID 6 | export var AuthToken = process.env.TWILIO_AUTH_TOKEN 7 | export var defaultFrom = "BeelineSG" 8 | 9 | export var inTrial = false 10 | export var client = require("twilio")(AccountSID, AuthToken) 11 | 12 | import {RateLimitError} from './errors' 13 | 14 | const rateLimit = {} 15 | 16 | export function sendSMS (what) { 17 | // Ensure that SMS is sent at most once every 30 seconds to the same number 18 | assert(what.to) 19 | if (what.rateLimit && rateLimit[what.to]) throw new RateLimitError(`Too many SMS requests by ${what.to}`) 20 | setTimeout(() => delete rateLimit[what.to], 30000) 21 | rateLimit[what.to] = true 22 | 23 | if (inTrial || !what.from) { 24 | _.assign(what, {from: defaultFrom}) 25 | } 26 | 27 | // Temporarily disable alphanumeric IDs until Twilio fixes their problem 28 | // what.from = defaultFrom; 29 | 30 | return BlueBird.promisify(client.messages.create, {context: client.messages})(what) 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/util/svy21.js: -------------------------------------------------------------------------------- 1 | var proj4 = require("proj4") 2 | 3 | proj4.defs([ 4 | ["epsg:3414", 5 | "+proj=tmerc +lat_0=1.366666666666667 +lon_0=103.8333333333333 +k=1 +x_0=28001.642 +y_0=38744.572 +ellps=WGS84 +units=m +no_defs "] 6 | ]) 7 | 8 | module.exports.toSVY = proj4("epsg:3414").forward 9 | module.exports.toWGS = proj4("epsg:3414").inverse 10 | 11 | -------------------------------------------------------------------------------- /test/assets.js: -------------------------------------------------------------------------------- 1 | const {models: m} = require('../src/lib/core/dbschema')() 2 | import {expect} from "code" 3 | import server from "../src/index" 4 | import Lab from "lab" 5 | import fs from "fs" 6 | import path from 'path' 7 | import _ from 'lodash' 8 | 9 | export var lab = Lab.script() 10 | 11 | lab.experiment("Assets stuff", function () { 12 | lab.test('publicHolidays', {timeout: 10000}, async function () { 13 | await m.Asset.destroy({where: {id: 'PublicHoliday'}}) 14 | await m.Asset.create({ 15 | id: 'PublicHoliday', 16 | data: fs.readFileSync((path.resolve(__dirname, 'ph.ics')), 'utf8') 17 | }) 18 | var response = await server.inject({ url: '/publicHolidays'}) 19 | expect(response.statusCode).equal(200) 20 | expect(response.result.length > 0) 21 | expect(_.keys(response.result[0]).includes('date')) 22 | expect(_.keys(response.result[0]).includes('summary')) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | import * as common from '../src/lib/util/common' 2 | import moment from "moment" 3 | import Lab from "lab" 4 | import {expect} from 'code' 5 | 6 | export const lab = Lab.script() 7 | 8 | lab.experiment("Common stuff", function () { 9 | lab.test('formatTime12', {timeout: 2000}, async function () { 10 | let dateTime = new Date(2016, 8, 1, 13, 8, 2) 11 | 12 | expect(common.formatTime12(dateTime)).equal('\u20071:08 PM') 13 | expect(common.formatTime12(dateTime.getTime())).equal('\u20071:08 PM') 14 | expect(common.formatTime12(dateTime.toISOString())).equal('\u20071:08 PM') 15 | 16 | dateTime = new Date(2016, 8, 1, 10, 8, 2) 17 | 18 | expect(common.formatTime12(dateTime)).equal('10:08 AM') 19 | expect(common.formatTime12(dateTime.getTime())).equal('10:08 AM') 20 | expect(common.formatTime12(dateTime.toISOString())).equal('10:08 AM') 21 | }) 22 | 23 | lab.test('formatTime24', {timeout: 2000}, async function () { 24 | let dateTime = new Date(2016, 8, 1, 13, 8, 2) 25 | 26 | expect(common.formatTime24(dateTime)).equal('13:08') 27 | expect(common.formatTime24(dateTime.getTime())).equal('13:08') 28 | expect(common.formatTime24(dateTime.toISOString())).equal('13:08') 29 | }) 30 | 31 | lab.test('formatDate', {timeout: 2000}, async function () { 32 | let dateTime = new Date(2016, 8, 1, 13, 8, 2) 33 | 34 | expect(common.formatDate(dateTime)).equal('1 Sep 2016') 35 | expect(common.formatDate(dateTime.getTime())).equal('1 Sep 2016') 36 | expect(common.formatDate(dateTime.toISOString())).equal('1 Sep 2016') 37 | }) 38 | 39 | lab.test('getNextDayInWeek', async function () { 40 | let date = moment("2018-09-28") // date falls on Friday 41 | 42 | // next Thursday 43 | expect(common.getNextDayInWeek(moment(date), 4).format("DD MM YYYY")).equal('04 10 2018') 44 | // next Friday 45 | expect(common.getNextDayInWeek(moment(date), 5).format("DD MM YYYY")).equal('28 09 2018') 46 | // next Sunday 47 | expect(common.getNextDayInWeek(moment(date), 7).format("DD MM YYYY")).equal('30 09 2018') 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/drivers.js: -------------------------------------------------------------------------------- 1 | var Lab = require("lab") 2 | var {expect} = require("code") 3 | export var lab = Lab.script() 4 | 5 | var server = require("../src/index.js") 6 | const {models} = require("../src/lib/core/dbschema")() 7 | 8 | var loginAs = require("./test_common").loginAs 9 | 10 | lab.experiment("Driver manipulation", function () { 11 | var companyInstance 12 | 13 | lab.before({timeout: 10000}, async function () { 14 | companyInstance = await models.TransportCompany.create({ 15 | name: "Test Transport Company" 16 | }) 17 | }) 18 | 19 | lab.after({timeout: 10000}, async function () { 20 | await companyInstance.destroy() 21 | }) 22 | 23 | lab.test("CRUD Drivers", {timeout: 10000}, async function () { 24 | var response = await loginAs("admin", { 25 | transportCompanyId: companyInstance.id, 26 | permissions: ['manage-drivers', 'view-drivers'] 27 | }) 28 | var sessionToken = response.result.sessionToken 29 | var authHeaders = { 30 | authorization: "Bearer " + sessionToken 31 | } 32 | 33 | await models.Driver.destroy({ 34 | where: { 35 | telephone: '12345678' 36 | } 37 | }) 38 | 39 | // Create a driver for this company 40 | response = await server.inject({ 41 | method: "POST", 42 | url: `/companies/${companyInstance.id}/drivers`, 43 | payload: { 44 | name: "Test Driver!!", 45 | telephone: '12345678' 46 | }, 47 | headers: authHeaders 48 | }) 49 | var driver = response.result 50 | expect(driver).to.contain("id") 51 | expect(driver).to.not.contain('passwordHash') 52 | expect(driver).to.not.contain('authKey') 53 | expect(driver).to.not.contain('pairingCode') 54 | 55 | // Expect the driver to appear in the list 56 | response = await server.inject({ 57 | method: 'GET', 58 | url: `/companies/${companyInstance.id}/drivers`, 59 | headers: authHeaders 60 | }) 61 | expect(response.result.find(d => d.id === driver.id)).exist() 62 | 63 | response = await server.inject({ 64 | method: "DELETE", 65 | url: `/companies/${companyInstance.id}/drivers/${driver.id}`, 66 | headers: authHeaders 67 | }) 68 | expect(response.statusCode).to.equal(200) 69 | 70 | // Expect the driver to appear in the list 71 | response = await server.inject({ 72 | method: 'GET', 73 | url: `/companies/${companyInstance.id}/drivers`, 74 | headers: authHeaders 75 | }) 76 | expect(response.result.find(d => d.id === driver.id)).not.exist() 77 | }) 78 | 79 | lab.test("Driver login", async function () { 80 | // Create a driver 81 | var telephone = '+6512344321' 82 | 83 | await models.Driver.destroy({ 84 | where: {telephone} 85 | }) 86 | var driverInst = await models.Driver.create({ 87 | telephone, 88 | name: 'Ah Kow' 89 | }) 90 | await driverInst.addTransportCompany(companyInstance.id) 91 | 92 | var sendResponse = await server.inject({ 93 | method: "POST", 94 | url: "/drivers/sendTelephoneVerification?dryRun=true", 95 | payload: {telephone} 96 | }) 97 | expect(sendResponse.statusCode).equal(200) 98 | 99 | // Extract code from database 100 | driverInst = await models.Driver.findById(driverInst.id) 101 | expect(driverInst.get('pairingCode', {raw: true})).exist() 102 | 103 | var loginResponse = await server.inject({ 104 | method: 'POST', 105 | url: '/drivers/verifyTelephone', 106 | payload: { 107 | telephone, 108 | code: driverInst.get('pairingCode', {raw: true}) 109 | } 110 | }) 111 | expect(loginResponse.statusCode).equal(200) 112 | expect(loginResponse.result.sessionToken).exist() 113 | expect(loginResponse.result.driver).exist() 114 | expect(loginResponse.result.driver.id).equal(driverInst.id) 115 | expect(loginResponse.result.driver.name).equal('Ah Kow') 116 | 117 | var driverAuth = { 118 | authorization: `Bearer ${driverInst.makeToken()}` 119 | } 120 | 121 | // update driver's own details 122 | var putResponse = await server.inject({ 123 | method: 'PUT', 124 | url: `/drivers/${driverInst.id}`, 125 | headers: driverAuth, 126 | payload: { 127 | name: 'Ah Seng' 128 | } 129 | }) 130 | expect(putResponse.statusCode).equal(200) 131 | expect(putResponse.result.name).equal('Ah Seng') 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /test/expireStaleRoutePasses.js: -------------------------------------------------------------------------------- 1 | import Lab from 'lab' 2 | export const lab = Lab.script() 3 | 4 | import {expect, fail} from 'code' 5 | 6 | import * as testData from './test_data' 7 | 8 | const {models} = require("../src/lib/core/dbschema")() 9 | 10 | const expireStaleRoutePasses = require("../src/lib/aws/expireStaleRoutePasses") 11 | 12 | lab.experiment("expireStaleRoutePasses", function () { 13 | let userInstance 14 | let companyInstance 15 | let routePassInstance 16 | let routePassPurchaseItem 17 | 18 | lab.before({timeout: 15000}, async () => { 19 | ({userInstance, companyInstance} = 20 | await testData.createUsersCompaniesRoutesAndTrips(models)) 21 | routePassInstance = await models.RoutePass.create({ 22 | userId: userInstance.id, companyId: companyInstance.id, tag: 'rp-101', status: 'valid', notes: { price: 10 } 23 | }) 24 | routePassPurchaseItem = await models.TransactionItem.create({ 25 | itemType: 'routePass', 26 | itemid: routePassInstance.id, 27 | debit: -10 28 | }) 29 | }) 30 | 31 | lab.after({timeout: 10000}, async () => { 32 | await routePassPurchaseItem.destroy() 33 | await models.RoutePass.destroy({ truncate: true }) 34 | }) 35 | 36 | lab.beforeEach({timeout: 10000}, async () => { 37 | await routePassInstance.update({ status: 'valid' }) 38 | }) 39 | 40 | lab.test("Route pass with fresh date remains untouched", {timeout: 10000}, async () => { 41 | await expireStaleRoutePasses.handler(undefined, undefined, err => { 42 | if (err) { 43 | fail(err) 44 | } 45 | }) 46 | await routePassInstance.reload() 47 | expect(routePassInstance.status).equal('valid') 48 | }) 49 | 50 | lab.test("Route pass with stale date is expired", {timeout: 10000}, async () => { 51 | await routePassInstance.update({ expiresAt: new Date(new Date().getTime() - 24 * 3600 * 1000) }) 52 | await expireStaleRoutePasses.handler(undefined, undefined, err => { 53 | if (err) { 54 | fail(err) 55 | } 56 | }) 57 | await routePassInstance.reload() 58 | expect(routePassInstance.status).equal('expired') 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/joi.js: -------------------------------------------------------------------------------- 1 | import Lab from 'lab' 2 | export const lab = Lab.script() 3 | 4 | const Joi = require("../src/lib/util/joi") 5 | import {expect} from 'code' 6 | 7 | lab.experiment("Telephone number validation", function () { 8 | const telephoneValidation = Joi.string().telephone() 9 | 10 | lab.test("Valid numbers", async function () { 11 | expect(Joi.attempt('+6581234567', telephoneValidation)).equal('+6581234567') 12 | expect(Joi.attempt('+65 8123 4567', telephoneValidation)).equal('+6581234567') 13 | expect(Joi.attempt('8123 4567', telephoneValidation)).equal('+6581234567') 14 | expect(Joi.attempt('912 345 67', telephoneValidation)).equal('+6591234567') 15 | }) 16 | 17 | lab.test("Invalid numbers", async function () { 18 | expect(() => Joi.assert('+65banana', telephoneValidation)).throw() 19 | expect(() => Joi.assert('+601234567', telephoneValidation)).throw() 20 | expect(() => Joi.assert('1234567', telephoneValidation)).throw() 21 | }) 22 | }) 23 | 24 | lab.experiment("Lat-Lng Validation", function () { 25 | const schema = Joi.latlng() 26 | 27 | lab.test("LatLng is converted to GeoJson", async function () { 28 | expect(Joi.attempt({lat: 1.38, lng: 103.8}, schema)) 29 | .equal({type: 'POINT', coordinates: [103.8, 1.38]}) 30 | }) 31 | 32 | lab.test("GeoJson is accepted too", async function () { 33 | expect(Joi.attempt({type: 'Point', coordinates: [103.8, 1.38]}, schema)) 34 | .equal({type: 'POINT', coordinates: [103.8, 1.38]}) 35 | expect(Joi.attempt({type: 'POINT', coordinates: [103.8, 1.38]}, schema)) 36 | .equal({type: 'POINT', coordinates: [103.8, 1.38]}) 37 | }) 38 | 39 | lab.test("Range limitations", async function () { 40 | expect(() => Joi.attempt({lat: 1.38, lng: 103.8}, schema.latRange([0, 1]))).throws() 41 | expect(() => Joi.attempt({lat: 1.38, lng: 103.8}, schema.latRange([1, 2]))).not.throws() 42 | 43 | expect(() => Joi.attempt({lat: 1.38, lng: 103.8}, schema.lngRange([100, 102]))).throws() 44 | expect(() => Joi.attempt({lat: 1.38, lng: 103.8}, schema.lngRange([102, 104]))).not.throws() 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /test/liteRoutes.js: -------------------------------------------------------------------------------- 1 | 2 | const Lab = require("lab") 3 | export const lab = Lab.script() 4 | 5 | const { expect } = require("code") 6 | const server = require("../src/index.js") 7 | 8 | const {models} = require("../src/lib/core/dbschema")() 9 | const {loginAs} = require("./test_common") 10 | const {createUsersCompaniesRoutesAndTrips} = require("./test_data") 11 | 12 | lab.experiment("Lite route retrievals", function () { 13 | let routeLabel = "L1" 14 | let [userInstance, companyInstance, routeInstance, tripInstances] = [] 15 | 16 | lab.before(async () => { 17 | 18 | ({userInstance, companyInstance, routeInstance, tripInstances} = 19 | await createUsersCompaniesRoutesAndTrips(models)) 20 | 21 | await routeInstance.update({ 22 | tags: ["lite"], 23 | label: routeLabel, 24 | }) 25 | }) 26 | 27 | lab.after(async () => { 28 | // cleanup our test user 29 | await models.Subscription.destroy({ 30 | where: { routeLabel }, 31 | }) 32 | await Promise.all(tripInstances.map(t => t.destroy())) 33 | await routeInstance.destroy() 34 | await companyInstance.destroy() 35 | await userInstance.destroy() 36 | }) 37 | 38 | lab.test("lite route retrieval", async function () { 39 | const readResponse = await server.inject({ 40 | method: "GET", 41 | url: "/routes/lite", 42 | }) 43 | expect(readResponse.statusCode).to.equal(200) 44 | expect(readResponse.result[routeLabel]._cached).to.equal(true) 45 | expect(readResponse.result[routeLabel].label).to.equal(routeLabel) 46 | expect(readResponse.result[routeLabel].startTime).to.exist() 47 | expect(readResponse.result[routeLabel].endTime).to.exist() 48 | }) 49 | 50 | lab.test("lite route retrieval", async function () { 51 | const readResponse = await server.inject({ 52 | method: "GET", 53 | url: "/routes/lite?label=" + routeLabel, 54 | }) 55 | expect(readResponse.statusCode).to.equal(200) 56 | expect(readResponse.result[routeLabel].label).to.equal(routeLabel) 57 | expect(readResponse.result[routeLabel].startTime).to.exist() 58 | expect(readResponse.result[routeLabel].endTime).to.exist() 59 | }) 60 | 61 | // lite subscriptions 62 | lab.test("lite route subscriptions CRUD", async function () { 63 | let user = userInstance 64 | // LOGIN 65 | let loginResponse = await loginAs("user", user.id) 66 | let headers = { 67 | authorization: "Bearer " + loginResponse.result.sessionToken, 68 | } 69 | // CREATE 70 | const createResponse = await server.inject({ 71 | method: "POST", 72 | url: `/routes/lite/${routeLabel}/subscription`, 73 | headers, 74 | }) 75 | expect(createResponse.statusCode).to.equal(200) 76 | expect(createResponse.result.status).to.equal("valid") 77 | 78 | // READ 79 | const readResponse = await server.inject({ 80 | method: "GET", 81 | url: "/routes/lite/subscriptions", 82 | headers, 83 | }) 84 | expect(readResponse.statusCode).to.equal(200) 85 | expect(readResponse.result[0].status).to.equal("valid") 86 | expect(readResponse.result[0].routeLabel).to.equal(routeLabel) 87 | 88 | const getLiteRoutesWithSubsResponse = await server.inject({ 89 | method: "GET", 90 | url: "/routes/lite", 91 | headers, 92 | }) 93 | expect(getLiteRoutesWithSubsResponse.statusCode).to.equal(200) 94 | expect(getLiteRoutesWithSubsResponse.result[routeLabel].isSubscribed).to.equal(true) 95 | expect(getLiteRoutesWithSubsResponse.result[routeLabel].label).to.equal(routeLabel) 96 | 97 | // DELETE 98 | const deleteResponse = await server.inject({ 99 | method: "DELETE", 100 | url: `/routes/lite/${routeLabel}/subscription`, 101 | headers, 102 | }) 103 | expect(deleteResponse.statusCode).to.equal(200) 104 | expect(deleteResponse.result.status).to.equal("invalid") 105 | 106 | const readAfterDeleteResponse = await server.inject({ 107 | method: "GET", 108 | url: "/routes/lite/subscriptions", 109 | headers, 110 | }) 111 | expect(readAfterDeleteResponse.statusCode).to.equal(200) 112 | expect(readAfterDeleteResponse.result.length).to.equal(0) 113 | 114 | const getLiteRoutesWithoutSubsResponse = await server.inject({ 115 | method: "GET", 116 | url: "/routes/lite", 117 | headers, 118 | }) 119 | expect(getLiteRoutesWithoutSubsResponse.statusCode).to.equal(200) 120 | expect(getLiteRoutesWithoutSubsResponse.result[routeLabel].isSubscribed).to.equal(false) 121 | expect(getLiteRoutesWithoutSubsResponse.result[routeLabel].label).to.equal(routeLabel) 122 | }) 123 | 124 | }) 125 | -------------------------------------------------------------------------------- /test/onemap.js: -------------------------------------------------------------------------------- 1 | const Lab = require("lab") 2 | const lab = exports.lab = Lab.script() 3 | 4 | import {expect} from "code" 5 | import _ from 'lodash' 6 | 7 | const server = require("../src/index.js") 8 | 9 | async function request () { 10 | const response = await server.inject({ 11 | path: '/onemap/revgeocode?' + querystring.stringify({ 12 | location: '1.336434280186183,103.8256072998047', 13 | otherFeatures: 'y', 14 | }) 15 | }) 16 | 17 | expect(response.data.GeocodeInfo).exist() 18 | expect(response.data.GeocodeInfo[0].LONGITUDE).exist() 19 | } 20 | 21 | lab.experiment("OneMap functions succeed", function () { 22 | 23 | lab.test("Onemap Revgeocode", async function (done) { 24 | request() // should be a fresh request 25 | request() // token should be cached 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/privacy.js: -------------------------------------------------------------------------------- 1 | var Lab = require("lab") 2 | export var lab = Lab.script() 3 | const {expect} = require("code") 4 | const {models: m} = require("../src/lib/core/dbschema")() 5 | 6 | lab.experiment("Privacy checks", function () { 7 | lab.test("Password hash", async function () { 8 | var userInstance = await m.User.create({ 9 | name: 'Some name', 10 | email: `test${Date.now()}@example.com`, 11 | password: 'Hello world!', 12 | }) 13 | 14 | var user = userInstance.get() 15 | 16 | expect(user.passwordHash).not.exist() 17 | expect(user.password).not.exist() 18 | expect(user.name).exist() 19 | expect(user.email).exist() 20 | }) 21 | 22 | lab.test("Company info", async function () { 23 | var companyInstance = await m.TransportCompany.create({ 24 | name: 'Some name', 25 | clientId: 'clientId', 26 | clientSecret: 'clientSecret', 27 | sandboxId: 'sandboxId', 28 | sandboxSecret: 'sandboxSecret', 29 | }) 30 | 31 | var company = companyInstance.get() 32 | 33 | expect(company.clientId).not.exist() 34 | expect(company.clientSecret).not.exist() 35 | expect(company.sandboxId).not.exist() 36 | expect(company.sandboxSecret).not.exist() 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /test/routePassDiscountQualifiers.js: -------------------------------------------------------------------------------- 1 | import Lab from 'lab' 2 | export const lab = Lab.script() 3 | 4 | import {expect} from 'code' 5 | import dateformat from 'dateformat' 6 | 7 | import { 8 | qualifyingFunctions 9 | } from '../src/lib/promotions/functions/routePassDiscountQualifiers' 10 | 11 | const { 12 | limitByCompany, 13 | limitByPurchaseDate, 14 | limitByRouteTags, 15 | n4m 16 | } = qualifyingFunctions 17 | 18 | lab.experiment("RoutePass discount qualifyingFunctions", {timeout: 10000}, function () { 19 | lab.test("limitByCompany", async () => { 20 | const params = {companyId: 12345} 21 | const check = limitByCompany(params) 22 | const unfiltered = [{companyId: 12345}, {companyId: 54321}] 23 | const filtered = check(unfiltered, {}) 24 | expect(filtered).to.have.length(1) 25 | expect(filtered[0].trip).to.equal(unfiltered[0].trip) 26 | }) 27 | 28 | lab.test("limitByRouteTags", async () => { 29 | const check = limitByRouteTags({ 30 | tags: ['some'] 31 | }) 32 | const unfiltered = [{ tags: ['none'] }, { tags: ['some'] }] 33 | const filtered = check(unfiltered, {}) 34 | expect(filtered).to.have.length(1) 35 | expect(filtered[0].trip).to.equal(unfiltered[1].trip) 36 | }) 37 | 38 | // FIXME: this is a clone of the one at ticketDiscountQualifiers 39 | lab.test("limitByPurchaseDate", async () => { 40 | const today = new Date() 41 | 42 | const dateToParams = (startOffset, endOffset) => ({ 43 | startDate: dateformat( 44 | new Date( 45 | today.getFullYear(), 46 | today.getMonth(), 47 | today.getDate() + startOffset, 48 | ), 49 | 'yyyy-mm-dd' 50 | ), 51 | endDate: dateformat( 52 | new Date( 53 | today.getFullYear(), 54 | today.getMonth(), 55 | today.getDate() + endOffset, 56 | ), 57 | 'yyyy-mm-dd' 58 | ), 59 | }) 60 | /* 61 | If start = D+ and end = D+endOffset, 62 | the array after checking should have length 63 | */ 64 | const check = (startOffset, endOffset, expectedLength) => { 65 | const fn = limitByPurchaseDate(dateToParams(startOffset, endOffset)) 66 | expect(fn([1, 2, 3])).length(expectedLength) 67 | } 68 | 69 | // All succeed 70 | check(0, 0, 3) 71 | check(-1, 1, 3) 72 | check(-1, 0, 3) 73 | check(0, 1, 3) 74 | 75 | // All fail 76 | check(1, -1, 0) // inverted dates 77 | check(-1, -2, 0) // before today 78 | check(1, 2, 0) // after today 79 | }) 80 | 81 | lab.test("n4m", async () => { 82 | let params, check, filtered 83 | const unfiltered = mockBalances(7.31, 5.27, 6.98, 4.21, 6.52) 84 | params = {n: 5, m: 4} 85 | check = n4m(params) 86 | filtered = check(unfiltered, {}) 87 | expect(filtered).to.have.length(1) 88 | expect(filtered[0].price).to.equal(unfiltered[3].price) 89 | params = {n: 2, m: 1} 90 | check = n4m(params) 91 | filtered = check(unfiltered, {}) 92 | expect(filtered).to.have.length(2) 93 | expect(filtered[0].price).to.equal(unfiltered[3].price) 94 | expect(filtered[1].price).to.equal(unfiltered[1].price) 95 | }) 96 | }) 97 | 98 | function mockBalances (...prices) { 99 | return prices.map(balance => ({balance})) 100 | } 101 | -------------------------------------------------------------------------------- /test/stops.js: -------------------------------------------------------------------------------- 1 | var Lab = require("lab") 2 | var lab = exports.lab = Lab.script() 3 | 4 | const {expect} = require("code") 5 | var _ = require("lodash") 6 | 7 | const {models: m} = require("../src/lib/core/dbschema")() 8 | const server = require("../src/index.js") 9 | const {loginAs} = require("./test_common") 10 | const {createUsersCompaniesRoutesAndTrips} = require('./test_data') 11 | 12 | lab.experiment("Stop manipulation", async () => { 13 | var testName = "Name for Testing" 14 | var updatedTestName = "Updated name for Testing" 15 | 16 | var stopId 17 | var stopInfo = { 18 | description: testName, 19 | road: "Testing road name", 20 | postcode: "Testing postcode", 21 | type: "Testing stop type", 22 | coordinates: { type: "Point", coordinates: [103.76073, 1.317370] } 23 | } 24 | var updatedStopInfo = { 25 | description: updatedTestName, 26 | road: "xTesting road name", 27 | postcode: "xTesting postcode", 28 | type: "xTesting stop type", 29 | coordinates: { type: "Point", coordinates: [103.99102, 1.350199] } 30 | } 31 | await m.TransportCompany.create({}) 32 | const authHeaders = await loginAs("superadmin") 33 | .then(resp => { 34 | return { authorization: "Bearer " + resp.result.sessionToken } 35 | }) 36 | 37 | lab.test("CRUD integration test", {timeout: 15000}, async () => { 38 | // CREATE 39 | var resp = await server.inject({ 40 | method: "POST", 41 | url: "/stops", 42 | payload: stopInfo, 43 | headers: authHeaders 44 | }) 45 | expect(resp.statusCode).to.equal(200) 46 | expect(resp.result).to.include("id") 47 | 48 | expect(_.isMatch(resp.result, stopInfo)).true() 49 | stopId = resp.result.id 50 | 51 | // READ 52 | resp = await server.inject({ 53 | method: "GET", 54 | url: "/stops/" + stopId 55 | }) 56 | expect(resp.statusCode).to.equal(200) 57 | expect(_.isMatch(resp.result, stopInfo)).true() 58 | 59 | // BULK READ 60 | resp = await server.inject({ 61 | method: "GET", 62 | url: "/stops" 63 | }) 64 | expect(resp.statusCode).to.equal(200) 65 | expect(resp.result.reduce( 66 | (current, stop) => current || _.isMatch(stop, stopInfo), 67 | false 68 | )).true() 69 | 70 | // UPDATE 71 | resp = await server.inject({ 72 | method: "PUT", 73 | url: "/stops/" + stopId, 74 | headers: authHeaders, 75 | payload: updatedStopInfo 76 | }) 77 | expect(resp.statusCode).to.equal(200) 78 | delete updatedStopInfo.id 79 | expect(resp.result.id).to.equal(stopId) 80 | expect(_.isMatch(resp.result, updatedStopInfo)).true() 81 | 82 | resp = await server.inject({ 83 | method: "GET", 84 | url: "/stops/" + stopId 85 | }) 86 | expect(_.isMatch(resp.result, updatedStopInfo)).true() 87 | 88 | // DELETE 89 | resp = await server.inject({ 90 | method: "DELETE", 91 | url: "/stops/" + stopId, 92 | headers: authHeaders 93 | }) 94 | expect(resp.statusCode).to.equal(200) 95 | expect(_.isMatch(resp.result, updatedStopInfo)).true() 96 | 97 | resp = await server.inject({ 98 | method: "GET", 99 | url: "/stops/" + stopId 100 | }) 101 | expect(resp.statusCode).to.equal(404) 102 | }) 103 | 104 | lab.test('should throw a 500 on hitting trip stops constraint errors', {timeout: 15000}, async () => { 105 | const {stopInstances} = await createUsersCompaniesRoutesAndTrips(m) 106 | 107 | const stopId = stopInstances[0].id 108 | 109 | const resp = await server.inject({ 110 | method: "DELETE", 111 | url: "/stops/" + stopId, 112 | headers: authHeaders 113 | }) 114 | expect(resp.statusCode).to.equal(409) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /test/stripe.js: -------------------------------------------------------------------------------- 1 | const {createStripeToken, calculateAdminFeeInCents, isMicro, stripe} = require("../src/lib/transactions/payment") 2 | const {expect} = require("code") 3 | let Lab = require("lab") 4 | export const lab = Lab.script() 5 | 6 | lab.experiment("Stripe Micro-transactions", function () { 7 | const stripeToken = () => { 8 | return createStripeToken({ 9 | number: "4242424242424242", 10 | exp_month: "12", 11 | exp_year: "2019", 12 | cvc: "123", 13 | }) 14 | } 15 | 16 | // XXX: all test credit cards issued by stripe calculate the admin fee 17 | // using the international rate, so we will not be able to verify the domestic one 18 | const adminFee = (amount) => { 19 | return calculateAdminFeeInCents(amount, isMicro(amount), false) 20 | } 21 | 22 | lab.test('Stripe rates have not changed', function (done) { 23 | let saveEnv = process.env.STRIPE_MICRO_RATES 24 | 25 | const STRIPE_MICRO_MIN_CHARGE = parseInt(process.env.STRIPE_MICRO_MIN_CHARGE) 26 | const STRIPE_MACRO_MIN_CHARGE = parseInt(process.env.STRIPE_MACRO_MIN_CHARGE) 27 | 28 | const STRIPE_MICRO_CHARGE_RATE = parseFloat(process.env.STRIPE_MICRO_CHARGE_RATE) 29 | const STRIPE_AMEXINTL_CHARGE_RATE = parseFloat(process.env.STRIPE_AMEXINTL_CHARGE_RATE) 30 | 31 | process.env.STRIPE_MICRO_RATES = 'true' 32 | expect(adminFee(500)).equal(Math.round(500 * STRIPE_MICRO_CHARGE_RATE) + STRIPE_MICRO_MIN_CHARGE) 33 | expect(adminFee(1500)).equal(Math.round(1500 * STRIPE_AMEXINTL_CHARGE_RATE) + STRIPE_MACRO_MIN_CHARGE) 34 | 35 | process.env.STRIPE_MICRO_RATES = 'false' 36 | expect(adminFee(500)).equal(Math.round(500 * STRIPE_AMEXINTL_CHARGE_RATE) + STRIPE_MACRO_MIN_CHARGE) 37 | 38 | process.env.STRIPE_MICRO_RATES = saveEnv 39 | done() 40 | }) 41 | 42 | lab.test("(Failure => Your stripe microtxn settings are wrong)", {timeout: 50000}, async function () { 43 | let fee1 = 7.50 44 | let refund1 = 3.5 45 | let amount = Math.round(fee1 * 100) 46 | let applicationFee = adminFee(amount) 47 | let ism = isMicro(amount) 48 | let token = await stripeToken() 49 | 50 | let chargeDetails = { 51 | source: token.id, 52 | description: "Micropayment test #1 payment #1", 53 | amount, 54 | currency: "SGD", 55 | capture: true, 56 | } 57 | 58 | let stripeCharge = await stripe.charges.create(chargeDetails) 59 | 60 | // Get the balance transaction 61 | let stripeBalanceTxn = await stripe.balance 62 | .retrieveTransaction(stripeCharge.balance_transaction) 63 | 64 | expect(stripeBalanceTxn.fee) 65 | .equal(applicationFee) 66 | 67 | // Refund partially... 68 | let applicationFeeRefund = calculateAdminFeeInCents(Math.round((fee1 - refund1) * 100), ism, false) - 69 | calculateAdminFeeInCents(Math.round(fee1 * 100), ism, false) 70 | 71 | let stripeRefund = await stripe.refunds.create({ 72 | charge: stripeCharge.id, 73 | amount: Math.round(refund1 * 100), 74 | }) 75 | 76 | // Check the balance transaction 77 | stripeBalanceTxn = await stripe.balance 78 | .retrieveTransaction(stripeRefund.balance_transaction) 79 | 80 | expect(stripeBalanceTxn.fee) 81 | .equal(applicationFeeRefund) 82 | }) 83 | 84 | lab.test("Standard payment above and refund below µTxn Threshold", {timeout: 50000}, async function () { 85 | let fee1 = 10.50 86 | let refund1 = 3.5 87 | let amount = Math.round(fee1 * 100) 88 | let applicationFee = adminFee(amount) 89 | let ism = isMicro(amount) 90 | let token = await stripeToken() 91 | 92 | expect(ism).equal(false) 93 | 94 | let chargeDetails = { 95 | source: token.id, 96 | description: "Standard payment test #1 payment #1", 97 | amount, 98 | currency: "SGD", 99 | capture: true, 100 | } 101 | 102 | let stripeCharge = await stripe.charges.create(chargeDetails) 103 | 104 | // Get the balance transaction 105 | let stripeBalanceTxn = await stripe.balance 106 | .retrieveTransaction(stripeCharge.balance_transaction) 107 | 108 | expect(stripeBalanceTxn.fee).equal(applicationFee) 109 | 110 | // Refund partially... 111 | let applicationFeeRefund = calculateAdminFeeInCents(Math.round((fee1 - refund1) * 100), ism, false) - 112 | calculateAdminFeeInCents(Math.round(fee1 * 100), ism, false) 113 | 114 | let stripeRefund = await stripe.refunds.create({ 115 | charge: stripeCharge.id, 116 | amount: Math.round(refund1 * 100), 117 | }) 118 | 119 | // Check the balance transaction 120 | stripeBalanceTxn = await stripe.balance 121 | .retrieveTransaction(stripeRefund.balance_transaction) 122 | 123 | expect(stripeBalanceTxn.fee).equal(applicationFeeRefund) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /test/telegram.js: -------------------------------------------------------------------------------- 1 | import {expect} from "code" 2 | 3 | import Lab from "lab" 4 | const lab = exports.lab = Lab.script() 5 | 6 | import {randomEmail} from "./test_common" 7 | import {updateEventSubsToTelegram} from "../src/lib/util/telegram" 8 | 9 | const {models} = require("../src/lib/core/dbschema")() 10 | 11 | lab.experiment('telegram', () => { 12 | const telephone = '+6512345678' 13 | const updateAndValidateAdminInstanceWith = updateFunc => async () => { 14 | const adminInstance = await models.Admin.create({ telephone, email: randomEmail() }) 15 | const chatId = 7 16 | await updateEventSubsToTelegram(chatId, updateFunc(adminInstance)) 17 | await adminInstance.reload() 18 | expect(adminInstance.notes.telegramChatId).equal(chatId) 19 | await adminInstance.destroy() 20 | } 21 | lab.test('upgrade email admin subs to telegram', 22 | updateAndValidateAdminInstanceWith(adminInstance => ({ email: adminInstance.email }))) 23 | lab.test('upgrade phone admin subs to telegram', 24 | updateAndValidateAdminInstanceWith(adminInstance => ({ phone: adminInstance.telephone }))) 25 | 26 | const updateAndValidateEventSubscriptionWith = (handler, updateFunc) => async () => { 27 | const eventSub = await models.EventSubscription.create({ 28 | handler, agent: { telephone, email: randomEmail() } 29 | }) 30 | const chatId = 7 31 | await updateEventSubsToTelegram(chatId, updateFunc(eventSub)) 32 | await eventSub.reload() 33 | expect(eventSub.agent.notes.telegramChatId).equal(chatId) 34 | expect(eventSub.handler).equal('telegram') 35 | await eventSub.destroy() 36 | } 37 | lab.test('upgrade email admin subs to telegram', 38 | updateAndValidateEventSubscriptionWith('email', e => ({ email: e.agent.email }))) 39 | lab.test('upgrade phone admin subs to telegram', 40 | updateAndValidateEventSubscriptionWith('sms', e => ({ phone: e.agent.telephone }))) 41 | }) 42 | -------------------------------------------------------------------------------- /test/test_common.js: -------------------------------------------------------------------------------- 1 | /* eslint require-jsdoc: 0 */ 2 | const {expect} = require("code") 3 | import _ from "lodash" 4 | import jwt from "jsonwebtoken" 5 | import * as Payment from '../src/lib/transactions/payment' 6 | const {models} = require("../src/lib/core/dbschema")() 7 | const events = require('../src/lib/events/events') 8 | 9 | export async function loginAs (type, options) { 10 | let tokenPayload 11 | if (type === 'superadmin') { 12 | tokenPayload = { 13 | email: `test-${Date.now()}@example.com`, 14 | email_verified: true, 15 | name: `Test Test`, 16 | app_metadata: { 17 | roles: ['superadmin'], 18 | }, 19 | ...options, 20 | } 21 | } else if (type === 'admin') { 22 | let email = `test-admin-${Date.now()}@example.com` 23 | let adminInst = await models.Admin.create({ 24 | email, 25 | }) 26 | if (options.transportCompanyId) { 27 | await adminInst.addTransportCompany(options.transportCompanyId, { 28 | permissions: options.permissions, 29 | }) 30 | } 31 | return {result: {sessionToken: adminInst.makeToken()}, statusCode: 200} 32 | } else if (type === 'user') { 33 | tokenPayload = {role: 'user'} 34 | if (typeof options === 'number') { 35 | _.extend(tokenPayload, {userId: options}) 36 | } else { 37 | _.extend(tokenPayload, options) 38 | } 39 | } else if (type === 'driver') { 40 | let driverInst = await models.Driver.create({ 41 | name: `TestDriver${Date.now()}`, 42 | telephone: `TestDriver${Date.now()}`, 43 | }) 44 | tokenPayload = { 45 | role: 'driver', 46 | driverId: driverInst.id, 47 | } 48 | if (options.transportCompanyIds) { 49 | await driverInst.addCompanies(options.transportCompanyIds) 50 | } 51 | if (options.transportCompanyId) { 52 | await driverInst.addTransportCompany(options.transportCompanyId) 53 | } 54 | } 55 | 56 | /* Pretend to be an inject result */ 57 | return Promise.resolve({ 58 | result: { 59 | sessionToken: jwt.sign(tokenPayload, require("../src/lib/core/auth").secretKey), 60 | }, 61 | statusCode: 200, 62 | }) 63 | } 64 | 65 | export function randomEmail () { 66 | return `test-${Date.now()}-${Math.floor(Math.random() * 1e8)}@example.com` 67 | } 68 | 69 | export function suggestionToken (email) { 70 | return jwt.sign( 71 | {email, email_verified: true}, 72 | new Buffer(process.env.PUBLIC_AUTH0_SECRET, 'base64')) 73 | } 74 | 75 | export function randomString (n = 10) { 76 | let s = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ' 77 | let t = '' 78 | 79 | for (let i = 0; i < n; i++) { 80 | t += s.charAt(Math.floor(Math.random() * s.length)) 81 | } 82 | return t 83 | } 84 | 85 | export function randomSingaporeLngLat () { 86 | return [ 87 | 103.69 + Math.random() * 0.29, 88 | 1.286 + Math.random() * 0.18, 89 | ] 90 | } 91 | 92 | export function defaultErrorHandler (reply) { 93 | return (err) => { 94 | console.error(err.stack) 95 | reply(err) 96 | } 97 | } 98 | 99 | export async function createStripeToken (cardNo) { 100 | return await Payment.createStripeToken({ 101 | number: cardNo || "4242424242424242", 102 | exp_month: "12", 103 | exp_year: "2019", 104 | cvc: "123", 105 | }).then(stripeToken => stripeToken.id) 106 | } 107 | 108 | export function expectEvent (eventName, params) { 109 | let rv = { 110 | isEmitted: false, 111 | async check () { 112 | // Delay a while, let the event be propagated 113 | await new Promise(resolve => setTimeout(resolve, 1000)) 114 | expect(this.isEmitted).true() 115 | this.remove() 116 | }, 117 | remove: null, 118 | } 119 | rv.remove = events.on(eventName, params, () => { 120 | rv.isEmitted = true 121 | }) 122 | return rv 123 | } 124 | 125 | export async function cleanlyDeleteUsers (where) { 126 | const userIds = (await models.User.findAll({where})).map(u => u.id) 127 | await models.Ticket.destroy({ 128 | where: { 129 | userId: {$in: userIds}, 130 | }, 131 | }) 132 | await models.Suggestion.destroy({ 133 | where: {userId: {$in: userIds}}, 134 | }) 135 | await models.Bid.destroy({ 136 | where: {userId: {$in: userIds}}, 137 | }) 138 | await models.User.destroy({where}) 139 | } 140 | 141 | export async function cleanlyDeletePromotions (where) { 142 | const promotionIds = (await models.Promotion.findAll({where})).map(u => u.id) 143 | await models.PromoUsage.destroy({ 144 | where: { 145 | promoId: {$in: promotionIds}, 146 | }, 147 | }) 148 | await models.Promotion.destroy({where}) 149 | } 150 | 151 | export async function resetTripInstances (models, tripInstances) { 152 | const destroyTickets = trip => 153 | trip.tripStops.map(stop => models.Ticket.destroy({ 154 | where: { boardStopId: stop.id }, 155 | })) 156 | 157 | const resetTrip = trip => Promise.all(destroyTickets(trip)) 158 | .then(() => models.Trip.update( 159 | { seatsAvailable: trip.capacity }, 160 | { where: { id: trip.id } } 161 | )) 162 | 163 | return Promise.all(tripInstances.map(resetTrip)) 164 | } 165 | -------------------------------------------------------------------------------- /test/tickets.js: -------------------------------------------------------------------------------- 1 | import { expect } from "code" 2 | import Lab from "lab" 3 | 4 | import server from "../src/index" 5 | import { 6 | resetTripInstances, 7 | createStripeToken, 8 | randomEmail, 9 | } from "./test_common" 10 | import { createUsersCompaniesRoutesAndTrips } from "./test_data" 11 | 12 | const { models: m } = require("../src/lib/core/dbschema")() 13 | 14 | export const lab = Lab.script() 15 | 16 | lab.experiment("tickets", function() { 17 | let userInstance 18 | let companyInstance 19 | let tripInstances 20 | let userToken 21 | 22 | lab.before({ timeout: 20000 }, async () => { 23 | ;({ 24 | userInstance, 25 | companyInstance, 26 | tripInstances, 27 | } = await createUsersCompaniesRoutesAndTrips(m)) 28 | userToken = { authorization: `Bearer ${userInstance.makeToken()}` } 29 | 30 | const purchaseItems = [ 31 | { 32 | tripId: tripInstances[0].id, 33 | boardStopId: tripInstances[0].tripStops[0].id, 34 | alightStopId: tripInstances[0].tripStops[4].id, 35 | }, 36 | { 37 | tripId: tripInstances[1].id, 38 | boardStopId: tripInstances[1].tripStops[0].id, 39 | alightStopId: tripInstances[1].tripStops[4].id, 40 | }, 41 | { 42 | tripId: tripInstances[2].id, 43 | boardStopId: tripInstances[2].tripStops[0].id, 44 | alightStopId: tripInstances[2].tripStops[4].id, 45 | }, 46 | { 47 | tripId: tripInstances[3].id, 48 | boardStopId: tripInstances[3].tripStops[0].id, 49 | alightStopId: tripInstances[3].tripStops[4].id, 50 | }, 51 | { 52 | tripId: tripInstances[4].id, 53 | boardStopId: tripInstances[4].tripStops[0].id, 54 | alightStopId: tripInstances[4].tripStops[4].id, 55 | }, 56 | ] 57 | 58 | const previewResponse = await server.inject({ 59 | method: "POST", 60 | url: "/transactions/tickets/quote", 61 | payload: { 62 | trips: purchaseItems, 63 | }, 64 | headers: userToken, 65 | }) 66 | expect(previewResponse.statusCode).to.equal(200) 67 | 68 | const saleResponse = await server.inject({ 69 | method: "POST", 70 | url: "/transactions/tickets/payment", 71 | payload: { 72 | trips: purchaseItems, 73 | stripeToken: await createStripeToken(), 74 | }, 75 | headers: userToken, 76 | }) 77 | expect(saleResponse.statusCode).to.equal(200) 78 | }) 79 | 80 | /* 81 | Delete all the tickets after each transaction so that 82 | we don't get 'user already has ticket' errors, or unexpected 83 | capacity errors 84 | */ 85 | lab.after(async () => resetTripInstances(m, tripInstances)) 86 | 87 | lab.test( 88 | "query and update tickets with transportCompanyId", 89 | { timeout: 10000 }, 90 | async function() { 91 | // pull tickets 92 | let ticketResponse = await server.inject({ 93 | method: "GET", 94 | url: "/tickets", 95 | headers: userToken, 96 | }) 97 | 98 | expect(ticketResponse.statusCode).to.equal(200) 99 | expect(ticketResponse.result.length).equal(5) 100 | 101 | const adminInstance = await m.Admin.create({ 102 | email: randomEmail(), 103 | }) 104 | await adminInstance.addTransportCompany(companyInstance.id, { 105 | permissions: ["issue-tickets"], 106 | }) 107 | const adminToken = { 108 | authorization: `Bearer ${adminInstance.makeToken()}`, 109 | } 110 | 111 | const ticketId = ticketResponse.result[0].id 112 | 113 | const ticketStatusTo = async status => 114 | server.inject({ 115 | method: "PUT", 116 | url: `/tickets/${ticketId}/status`, 117 | headers: adminToken, 118 | payload: { status }, 119 | }) 120 | 121 | const ticket = await m.Ticket.findById(ticketId) 122 | 123 | expect((await ticketStatusTo("failed")).statusCode).equal(400) 124 | expect((await ticketStatusTo("void")).statusCode).equal(200) 125 | expect((await ticket.reload()).status).equal("void") 126 | expect((await ticketStatusTo("valid")).statusCode).equal(200) 127 | expect((await ticket.reload()).status).equal("valid") 128 | await ticket.update({ status: "failed" }) 129 | expect((await ticketStatusTo("void")).statusCode).equal(400) 130 | 131 | let transportCompanyId = companyInstance.id + 1 132 | 133 | let ticket2Response = await server.inject({ 134 | method: "GET", 135 | url: `/tickets?transportCompanyId=${transportCompanyId}`, 136 | headers: userToken, 137 | }) 138 | 139 | expect(ticket2Response.statusCode).to.equal(200) 140 | expect(ticket2Response.result.length).equal(0) 141 | } 142 | ) 143 | }) 144 | -------------------------------------------------------------------------------- /test/timemachine-wrap.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | module.exports = require('timemachine') 4 | module.exports.reset() 5 | -------------------------------------------------------------------------------- /test/tripAvailability.js: -------------------------------------------------------------------------------- 1 | const Lab = require("lab") 2 | const lab = exports.lab = Lab.script() 3 | 4 | const {expect} = require("code") 5 | 6 | const {models} = require('../src/lib/core/dbschema')() 7 | const {resetTripInstances} = require("./test_common") 8 | const {createUsersCompaniesRoutesAndTrips} = require("./test_data") 9 | 10 | lab.experiment("Trip availability update hooks in Ticket", () => { 11 | var userInstance, tripInstances 12 | 13 | lab.before({timeout: 15000}, async () => { 14 | ({userInstance, tripInstances} = await createUsersCompaniesRoutesAndTrips(models)) 15 | }) 16 | 17 | lab.afterEach(async () => resetTripInstances(models, tripInstances)) 18 | 19 | const createTicket = () => models.Ticket.create({ 20 | userId: userInstance.id, 21 | tripId: tripInstances[0].id, 22 | boardStopId: tripInstances[0].tripStops[0].id, 23 | alightStopId: tripInstances[0].tripStops[4].id, 24 | status: 'valid', 25 | }) 26 | 27 | lab.test('Ticket creation decrements trip availability', async () => { 28 | await createTicket() 29 | await tripInstances[0].reload() 30 | expect(tripInstances[0].seatsAvailable).equal(tripInstances[0].capacity - 1) 31 | }) 32 | 33 | lab.test('Ticket status change adjusts trip availability', async () => { 34 | const ticket = await createTicket() 35 | await ticket.update({ status: 'void' }) 36 | await tripInstances[0].reload() 37 | expect(tripInstances[0].seatsAvailable).equal(tripInstances[0].capacity) 38 | await ticket.update({ status: 'valid' }) 39 | await tripInstances[0].reload() 40 | expect(tripInstances[0].seatsAvailable).equal(tripInstances[0].capacity - 1) 41 | }) 42 | 43 | lab.test('Ticket re-valid -> no availability change', async () => { 44 | const ticket = await createTicket() 45 | await ticket.update({ status: 'pending' }) 46 | await tripInstances[0].reload() 47 | expect(tripInstances[0].seatsAvailable).equal(tripInstances[0].capacity - 1) 48 | }) 49 | 50 | lab.test('Ticket re-invalid -> no availability change', async () => { 51 | const ticket = await createTicket() 52 | await ticket.update({ status: 'void' }) 53 | await tripInstances[0].reload() 54 | expect(tripInstances[0].seatsAvailable).equal(tripInstances[0].capacity) 55 | await ticket.update({ status: 'refunded' }) 56 | await tripInstances[0].reload() 57 | expect(tripInstances[0].seatsAvailable).equal(tripInstances[0].capacity) 58 | }) 59 | 60 | lab.test('afterBulkUpdate is disallowed', async () => { 61 | const ticket = await createTicket() 62 | try { 63 | await models.Ticket.update( 64 | { status: 'void' }, 65 | { where: { id: ticket.id }} 66 | ) 67 | throw new Error('Test Failure') 68 | } catch (err) { 69 | expect(err.name).equal('TransactionError') 70 | } 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /test/tripStatuses.js: -------------------------------------------------------------------------------- 1 | /* eslint no-await-in-loop: 0 */ 2 | const Lab = require("lab") 3 | export const lab = Lab.script() 4 | 5 | const {expect} = require("code") 6 | const server = require("../src/index.js") 7 | 8 | const {models: m} = require("../src/lib/core/dbschema")() 9 | 10 | lab.experiment("TripStatus manipulation", function () { 11 | const destroyList = [] 12 | let driver 13 | let vehicle 14 | let trip 15 | let company 16 | 17 | lab.before({timeout: 10000}, async function () { 18 | company = await m.TransportCompany.create({ 19 | name: "Test Transport Company", 20 | }) 21 | destroyList.push(company) 22 | 23 | driver = await m.Driver.create({ 24 | name: "Tan Ah Test", 25 | telephone: "12345678", 26 | authKey: "---", 27 | transportCompanyId: company.id, 28 | }) 29 | await driver.addTransportCompany(company) 30 | destroyList.push(driver) 31 | 32 | vehicle = await m.Vehicle.create({ 33 | vehicleNumber: "SXX0000Y", 34 | driverId: driver.id, 35 | }) 36 | destroyList.push(vehicle) 37 | 38 | trip = await m.Trip.create({ 39 | date: "2015-04-04", 40 | capacity: 1, 41 | status: "TESTING", 42 | price: "1.00", 43 | transportCompanyId: company.id, 44 | vehicleId: vehicle.id, 45 | driverId: driver.id, 46 | routeId: null, 47 | }) 48 | destroyList.push(trip) 49 | }) 50 | 51 | lab.after(async function () { 52 | for (let it of destroyList.reverse()) { 53 | await it.destroy() 54 | } 55 | }) 56 | 57 | lab.test("Create tripStatuses", async function () { 58 | const authHeaders = { 59 | authorization: `Bearer ${driver.makeToken()}`, 60 | } 61 | 62 | const messages = ["OK", "+5min", "+15min", "+30min"] 63 | 64 | // Trip status can be updated by the driver, 65 | // provided he is the driver for the trip 66 | trip.driverId = driver.id 67 | await trip.save() 68 | 69 | // create some tripStatuses... 70 | for (let message of messages) { 71 | const response = await server.inject({ 72 | method: "POST", 73 | url: `/trips/${trip.id}/messages`, 74 | payload: { 75 | message, 76 | }, 77 | headers: authHeaders, 78 | }) 79 | const tripStatus = response.result 80 | expect(response.statusCode).to.equal(200) 81 | expect(tripStatus.message).to.exist() 82 | expect(tripStatus.time).to.exist() 83 | expect(tripStatus.creator).to.exist() 84 | } 85 | 86 | // GET tripStatuses? 87 | const { result: initialResult } = await server.inject({ 88 | method: "GET", 89 | url: `/trips/${trip.id}`, 90 | }) 91 | for (let message of messages) { 92 | expect(initialResult.messages.map(x => x.message)).to.include(message) 93 | } 94 | expect(initialResult.messages.length).to.equal(messages.length) 95 | 96 | await server.inject({ 97 | method: "POST", 98 | url: `/trips/${trip.id}/messages`, 99 | payload: { 100 | message: "cancel", 101 | status: "cancelled", 102 | }, 103 | headers: authHeaders, 104 | }) 105 | 106 | const { result } = await server.inject({ 107 | method: "GET", 108 | url: `/trips/${trip.id}`, 109 | }) 110 | expect(result.status).to.equal("cancelled") 111 | for (let message of messages) { 112 | expect(result.messages.map(x => x.message)).to.include(message) 113 | } 114 | 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /test/uploadLogo.html: -------------------------------------------------------------------------------- 1 |
5 | 6 | 7 | 8 | 9 | 10 |
; 11 | -------------------------------------------------------------------------------- /test/uploadLogoForbidden.html: -------------------------------------------------------------------------------- 1 |
5 | 6 | 7 | 8 | 9 | 10 |
; 11 | -------------------------------------------------------------------------------- /test/uploadPhoto.html: -------------------------------------------------------------------------------- 1 |
5 | 6 | 7 | 8 | 9 | 10 |
; 11 | -------------------------------------------------------------------------------- /test/uploadPhotoForbidden.html: -------------------------------------------------------------------------------- 1 |
5 | 6 | 7 | 8 | 9 | 10 |
; 11 | -------------------------------------------------------------------------------- /test/userPaymentInfo.js: -------------------------------------------------------------------------------- 1 | const Lab = require("lab") 2 | const {expect} = require("code") 3 | const server = require("../src/index.js") 4 | const {createStripeToken, cleanlyDeleteUsers} = require("./test_common") 5 | const {models} = require("../src/lib/core/dbschema")() 6 | 7 | const lab = exports.lab = Lab.script() 8 | 9 | let createMasterStripeToken = async function () { 10 | return createStripeToken("5555555555554444") 11 | } 12 | 13 | 14 | lab.experiment("Payment info manipulation", function () { 15 | lab.before({timeout: 10000}, function (done) { 16 | if (server.info.started) { 17 | return done() 18 | } else { 19 | server.on('start', () => done()) 20 | } 21 | }) 22 | 23 | lab.test('CRUD Payment info', {timeout: 20000}, async function () { 24 | /* Cleanly delete the user */ 25 | await cleanlyDeleteUsers({ 26 | telephone: '+6581001860', 27 | }) 28 | 29 | const userInst = await models.User.create({ 30 | telephone: '+6581001860', 31 | }) 32 | const headers = { 33 | authorization: `Bearer ${userInst.makeToken()}`, 34 | } 35 | 36 | // get the card details... should have nothing 37 | const getResponse = await server.inject({ 38 | method: 'GET', 39 | url: `/users/${userInst.id}/creditCards`, 40 | headers, 41 | }) 42 | expect(getResponse.statusCode).equal(200) 43 | expect(getResponse.result).not.exist() 44 | 45 | // Insert some credit card details 46 | const postResponse = await server.inject({ 47 | method: 'POST', 48 | url: `/users/${userInst.id}/creditCards`, 49 | headers, 50 | payload: { 51 | stripeToken: await createStripeToken(), 52 | }, 53 | }) 54 | expect(postResponse.statusCode).equal(200) 55 | expect(postResponse.result.sources.data[0]).exist() 56 | expect(postResponse.result.sources.data[0].last4).equal('4242') 57 | expect(postResponse.result.sources.data.length).equal(1) 58 | 59 | 60 | // Update with another card 61 | const putResponse = await server.inject({ 62 | method: 'POST', 63 | url: `/users/${userInst.id}/creditCards/replace`, 64 | headers, 65 | payload: { 66 | stripeToken: await createMasterStripeToken(), 67 | }, 68 | }) 69 | expect(putResponse.statusCode).equal(200) 70 | expect(putResponse.result.sources.data[0]).exist() 71 | expect(putResponse.result.sources.data[0].last4).equal('4444') 72 | expect(postResponse.result.sources.data.length).equal(1) 73 | 74 | 75 | // get the card details... should have something now 76 | const getResponse2 = await server.inject({ 77 | method: 'GET', 78 | url: `/users/${userInst.id}/creditCards`, 79 | headers, 80 | }) 81 | expect(getResponse2.statusCode).equal(200) 82 | expect(getResponse2.result).exist() 83 | expect(getResponse2.result.sources.data[0].last4).equal('4444') 84 | expect(postResponse.result.sources.data.length).equal(1) 85 | 86 | 87 | // Delete card details 88 | const deleteResponse = await server.inject({ 89 | method: 'DELETE', 90 | url: `/users/${userInst.id}/creditCards/${getResponse2.result.sources.data[0].id}`, 91 | headers, 92 | }) 93 | expect(deleteResponse.statusCode).equal(200) 94 | expect(deleteResponse.result).exist() 95 | expect(deleteResponse.result.sources.data[0]).not.exist() 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /test/userSuggestedRoutes.js: -------------------------------------------------------------------------------- 1 | const Lab = require("lab") 2 | export const lab = Lab.script() 3 | const {expect} = require("code") 4 | const server = require("../src/index.js") 5 | const {models: m} = require("../src/lib/core/dbschema")() 6 | const _ = require("lodash") // from 'lodash' 7 | const {randomSingaporeLngLat, randomEmail, suggestionToken} = require("./test_common") 8 | const sinon = require('sinon') 9 | 10 | lab.experiment("User Suggested Routes", function () { 11 | let sandbox 12 | 13 | lab.beforeEach(async () => { 14 | sandbox = sinon.sandbox.create() 15 | }) 16 | 17 | lab.afterEach(async () => { 18 | sandbox.restore() 19 | }) 20 | 21 | lab.test("Create/delete User-suggested Routes", {timeout: 5000}, async function () { 22 | const lngLats = _.range(0, 10).map(_ => randomSingaporeLngLat()) 23 | const name = 'My Name' 24 | const email = randomEmail() 25 | 26 | const stopInstances = await Promise.all(lngLats.map((lngLat, index) => m.Stop.create({ 27 | description: `Random Stop ${index}`, 28 | coordinates: { 29 | type: 'Point', 30 | coordinates: lngLat 31 | } 32 | }))) 33 | 34 | const createResponse = await server.inject({ 35 | url: '/modules/user_suggestions/routes', 36 | method: 'POST', 37 | payload: { 38 | name, 39 | busStops: lngLats.map((lngLat, index) => ({ 40 | coordinates: { 41 | type: 'Point', 42 | coordinates: lngLat, 43 | }, 44 | arriveAt: (6 + index * 0.1) * 3600 * 1000 45 | })), 46 | path: { 47 | type: 'LineString', 48 | coordinates: lngLats 49 | }, 50 | }, 51 | headers: { 52 | authorization: 'Bearer ' + suggestionToken(email) 53 | } 54 | }) 55 | 56 | expect(createResponse.statusCode).equal(200) 57 | expect(createResponse.result.userSuggestedRouteStops.length).equal(lngLats.length) 58 | 59 | for (let [usrs, stop] of _.zip(createResponse.result.userSuggestedRouteStops, stopInstances)) { 60 | expect(usrs.stopId).equal(stop.id) 61 | } 62 | 63 | const getResponse = await server.inject({ 64 | url: '/modules/user_suggestions/routes', 65 | method: 'GET', 66 | headers: { 67 | authorization: 'Bearer ' + suggestionToken(email) 68 | } 69 | }) 70 | expect(getResponse.result.length).equal(1) 71 | expect(getResponse.result[0].name).equal(name) 72 | expect(getResponse.result[0].path.coordinates).equal(lngLats) 73 | expect(getResponse.result[0].userSuggestedRouteStops.length).equal(lngLats.length) 74 | expect(_.sortBy(getResponse.result[0].userSuggestedRouteStops, 'arriveAt')) 75 | .equal(getResponse.result[0].userSuggestedRouteStops) 76 | expect(getResponse.result[0].id).equal(createResponse.result.id) 77 | 78 | const deleteResponse = await server.inject({ 79 | url: `/modules/user_suggestions/routes/${getResponse.result[0].id}`, 80 | method: 'DELETE', 81 | headers: { 82 | authorization: 'Bearer ' + suggestionToken(email) 83 | } 84 | }) 85 | expect(deleteResponse.statusCode).equal(200) 86 | expect(await m.UserSuggestedRouteStop.findById(getResponse.result[0].id)).not.exist() 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /test/vehicles.js: -------------------------------------------------------------------------------- 1 | var Lab = require("lab") 2 | export var lab = Lab.script() 3 | 4 | const {expect} = require("code") 5 | var server = require("../src/index") 6 | 7 | const {models: m} = require("../src/lib/core/dbschema")() 8 | 9 | lab.experiment("Vehicle manipulation", function () { 10 | var destroyList = [] 11 | lab.before({timeout: 10000}, async function () { 12 | }) 13 | 14 | lab.after({timeout: 10000}, async function () { 15 | for (let it of destroyList.reverse()) { 16 | await it.destroy() // eslint-disable-line no-await-in-loop 17 | } 18 | }) 19 | 20 | lab.test("Pair vehicle", async function () { 21 | var companyInstance = await m.TransportCompany.create({ 22 | name: "Test Transport Company" 23 | }) 24 | destroyList.push(companyInstance) 25 | 26 | var driver = await m.Driver.create({ 27 | name: "Tan Ah Test", 28 | telephone: "12345678", 29 | authKey: "---", 30 | }) 31 | await driver.addTransportCompany(companyInstance.id) 32 | destroyList.push(driver) 33 | 34 | var authHeaders = { 35 | authorization: `Bearer ${driver.makeToken()}` 36 | } 37 | 38 | var response = await server.inject({ 39 | method: "POST", 40 | url: "/vehicles", 41 | payload: { 42 | vehicleNumber: "SAB1234X" 43 | }, 44 | headers: authHeaders 45 | }) 46 | var vehicle = response.result 47 | expect(response.statusCode).to.equal(200) 48 | expect(vehicle).to.contain("id") 49 | expect(vehicle.driverId).to.equal(driver.id) 50 | 51 | // No duplicates! 52 | response = await server.inject({ 53 | method: "POST", 54 | url: "/vehicles", 55 | payload: { 56 | vehicleNumber: "SAB1234X" 57 | }, 58 | headers: authHeaders 59 | }) 60 | expect(response).to.not.equal(200) 61 | 62 | // Update is not strictly speaking necessary is it? 63 | // FIXME: HAPI does not support testing file upload 64 | // var identicon = Identicon.generateSync({ 65 | // id: 'RandomTest' + Math.random(), 66 | // size: 100, 67 | // }); 68 | // var response = await server.inject({ 69 | // method: 'POST', 70 | // url: '/vehicles/' + vehicle.id + '/photo', 71 | // payload: { 72 | // 73 | // }, 74 | // 75 | // }); 76 | 77 | // Delete 78 | response = await server.inject({ 79 | method: "DELETE", 80 | url: "/vehicles/" + vehicle.id, 81 | headers: authHeaders 82 | }) 83 | vehicle = response.result 84 | expect(response.statusCode).to.equal(200) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /test/zeroDollarTransactions.js: -------------------------------------------------------------------------------- 1 | import Lab from 'lab' 2 | import server from '../src/index.js' 3 | import {expect} from 'code' 4 | 5 | import {randomString} from './test_common' 6 | import {createUsersCompaniesRoutesAndTrips} from './test_data' 7 | 8 | export const lab = Lab.script() 9 | const {models} = require('../src/lib/core/dbschema')() 10 | 11 | lab.experiment("Zero dollar transactions", function () { 12 | var authHeaders = {} 13 | var userInstance, routeInstance, tripInstances 14 | var promotionInstance 15 | 16 | lab.before({timeout: 15000}, async () => { 17 | ({userInstance, routeInstance, tripInstances} = 18 | await createUsersCompaniesRoutesAndTrips(models)) 19 | 20 | var userToken = userInstance.makeToken() 21 | authHeaders.user = {authorization: "Bearer " + userToken} 22 | 23 | promotionInstance = await models.Promotion.create({ 24 | code: randomString(), 25 | type: 'Promotion', 26 | params: { 27 | discountFunction: { 28 | type: 'simpleRate', 29 | params: { rate: 0.99 } 30 | }, 31 | refundFunction: { type: 'refundDiscountedAmt'}, 32 | qualifyingCriteria: [ 33 | {type: 'limitByRoute', params: {routeIds: [routeInstance.id]}}, 34 | ], 35 | usageLimit: { 36 | userLimit: null, 37 | globalLimit: null 38 | } 39 | } 40 | }) 41 | }) 42 | 43 | lab.test("Highly discounted transactions don't require payment", async () => { 44 | const previewResponse = await server.inject({ 45 | method: "POST", 46 | url: "/transactions/tickets/quote", 47 | payload: { 48 | trips: [{ 49 | tripId: tripInstances[0].id, 50 | boardStopId: tripInstances[0].tripStops[0].id, 51 | alightStopId: tripInstances[0].tripStops[0].id, 52 | }], 53 | promoCode: { 54 | code: promotionInstance.code 55 | }, 56 | }, 57 | headers: authHeaders.user 58 | }) 59 | expect(previewResponse.statusCode).equal(200) 60 | expect(previewResponse.result.transactionItems.find(ti => ti.itemType === 'payment').debit === '0.00') 61 | 62 | const saleResponse = await server.inject({ 63 | method: "POST", 64 | url: "/transactions/tickets/payment", 65 | payload: { 66 | trips: [{ 67 | tripId: tripInstances[0].id, 68 | boardStopId: tripInstances[0].tripStops[0].id, 69 | alightStopId: tripInstances[0].tripStops[0].id, 70 | }], 71 | promoCode: { 72 | code: promotionInstance.code 73 | }, 74 | stripeToken: "Token is not necessary" 75 | }, 76 | headers: authHeaders.user 77 | }) 78 | 79 | expect(saleResponse.statusCode).equal(200) 80 | expect(saleResponse.result.transactionItems.find(ti => ti.itemType === 'payment').debit === '0.00') 81 | 82 | const tickets = await models.Ticket.findAll({ 83 | where: { 84 | userId: userInstance.id, 85 | boardStopId: tripInstances[0].tripStops[0].id, 86 | status: 'valid' 87 | } 88 | }) 89 | expect(tickets.length).equal(1) 90 | }) 91 | }) 92 | --------------------------------------------------------------------------------