├── .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 |
16 | {{#each query}}
17 | - {{@key}} = {{this}}
18 | {{/each}}
19 |
20 |
21 | Tickets Sold
22 |
23 |
24 |
25 |
26 | Date |
27 | Ticket ID |
28 | Route |
29 | Credit |
30 |
31 |
32 |
33 |
34 | {{#each data.ticketSale}}
35 |
36 |
37 | {{formatDate createdAt}}
38 | |
39 |
40 | {{ticketSale.id}}
41 | |
42 |
43 | ({{ticketSale.boardStop.trip.route.label}})
44 | {{ticketSale.boardStop.trip.route.name}}
45 | |
46 |
47 | {{credit}}
48 | |
49 |
50 | {{/each}}
51 |
52 |
53 |
54 |
55 | |
56 | Total |
57 | {{sumCredit data.ticketSale}} |
58 |
59 |
60 |
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 | Date |
75 | Charge ID |
76 | Credit |
77 |
78 |
79 |
80 |
81 | {{#each data.payment}}
82 |
83 |
84 | {{formatDate createdAt}}
85 | |
86 |
87 | {{payment.paymentResource}}
88 | |
89 |
90 | {{debit}}
91 | |
92 |
93 | {{/each}}
94 |
95 |
96 |
97 |
98 | |
99 | Total |
100 | {{sumCredit data.ticketSale}} |
101 |
102 |
103 |
104 |
105 | Refunds Paid Out
106 |
107 |
108 |
109 |
110 | Date |
111 | Route |
112 | Credit |
113 |
114 |
115 |
116 |
117 | {{#each data.refundPayment}}
118 |
119 |
120 | {{createdAt}}
121 | |
122 |
123 | {{refundPayment.paymentResource}}
124 | |
125 |
126 | {{debit}}
127 | |
128 |
129 | {{/each}}
130 |
131 |
132 |
133 |
134 | |
135 | Total |
136 | {{sumCredit data.refundPayment}} |
137 |
138 |
139 |
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 | ;
11 |
--------------------------------------------------------------------------------
/test/uploadLogoForbidden.html:
--------------------------------------------------------------------------------
1 | ;
11 |
--------------------------------------------------------------------------------
/test/uploadPhoto.html:
--------------------------------------------------------------------------------
1 | ;
11 |
--------------------------------------------------------------------------------
/test/uploadPhotoForbidden.html:
--------------------------------------------------------------------------------
1 | ;
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 |
--------------------------------------------------------------------------------