ti2 1.0.16 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.js CHANGED
@@ -42,4 +42,5 @@ module.exports = {
42
42
  },
43
43
  ],
44
44
  },
45
+ ignorePatterns: ['**/docs/*'],
45
46
  };
@@ -7,7 +7,7 @@ jobs:
7
7
  timeout-minutes: 10
8
8
  runs-on: ubuntu-latest
9
9
  container:
10
- image: node:erbium
10
+ image: node:16.15.0
11
11
  services:
12
12
  mysql:
13
13
  image: mysql
@@ -17,9 +17,12 @@ jobs:
17
17
  MYSQL_ROOT_PASSWORD: root
18
18
  ports:
19
19
  - 3306:3306
20
+ redis:
21
+ image: redis
20
22
  env:
21
23
  CI: true
22
24
  DB_URL: 'mysql://root:root@mysql/ti2development'
25
+ REDIS_URL: 'redis://redis:6379'
23
26
  dbCryptoKey: 'YmYzYzE5OWMyNDcwY2I0NzdkOTA3YjFlMDkxN2MxN2I='
24
27
  jwtSecret: '9rifawckwztuKehocJOtGM6deTqICGWzIyc5CqgNyi'
25
28
  frontendKey: 'lYjEJHzb7lbQ1hk3ypjX'
@@ -30,10 +33,11 @@ jobs:
30
33
  with:
31
34
  node-version: 12
32
35
  - name: install dependencies
33
- run: npm i
36
+ run: npm ci
34
37
  - name: create/migrate the database
35
38
  run: npx sequelize db:create && npx sequelize db:migrate
36
39
  - name: seed the database
37
40
  run: npx sequelize db:seed:all
41
+ - run: RUNNER_TRACKING_ID="" && (nohup node ./test/worker.js&)
38
42
  - name: run the tests
39
43
  run: npx jest -i --coverage --forceExit
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ v16.15.0
package/api.yml CHANGED
@@ -29,6 +29,34 @@ components:
29
29
  properties:
30
30
  value:
31
31
  type: string
32
+ JobSpec:
33
+ type: object
34
+ properties:
35
+ payload:
36
+ type: object
37
+ description: The job app parameters
38
+ required: true
39
+ example: '{
40
+ "method": "dailyReport"
41
+ }'
42
+ jobParams:
43
+ type: object
44
+ description: Additional bull job params
45
+ example: '{
46
+ "delay": "5000"
47
+ }'
48
+ JobStatus:
49
+ type: object
50
+ properties:
51
+ jobId:
52
+ type: string
53
+ status:
54
+ type: string
55
+ example: 'active'
56
+ result:
57
+ type: object
58
+ description: The job return values
59
+ example: '{ "result": "value" }'
32
60
  App:
33
61
  type: object
34
62
  properties:
@@ -534,6 +562,128 @@ paths:
534
562
  content:
535
563
  application/json:
536
564
  schema: '#/components/schemas/Message'
565
+
566
+ /{app}/{userId}/jobRun:
567
+ security:
568
+ - bearerAuth: ['app', 'user', 'admin']
569
+ post:
570
+ tags:
571
+ - jobs
572
+ summary: Run a Job
573
+ security:
574
+ - bearerAuth: ['app', 'user', 'admin']
575
+ operationId: runAppJob
576
+ parameters:
577
+ - name: app
578
+ in: path
579
+ description: app that defines the job
580
+ example: 'ventrata'
581
+ required: true
582
+ schema:
583
+ type: string
584
+ - name: userId
585
+ in: path
586
+ description: user to run the schedule app against
587
+ example: '536830b6ed19afa44a000002'
588
+ required: true
589
+ schema:
590
+ type: string
591
+ requestBody:
592
+ description: The job parameters
593
+ required: true
594
+ content:
595
+ application/json:
596
+ schema:
597
+ $ref: '#/components/schemas/JobSpec'
598
+ responses:
599
+ 200:
600
+ description: success
601
+ content:
602
+ application/json:
603
+ schema: '#/components/schemas/JobStatus'
604
+ /{app}/{userId}/{hint}/jobRun:
605
+ security:
606
+ - bearerAuth: ['app', 'user', 'admin']
607
+ post:
608
+ tags:
609
+ - jobs
610
+ summary: Run a schedule Job
611
+ security:
612
+ - bearerAuth: ['app', 'user', 'admin']
613
+ operationId: runAppJob
614
+ parameters:
615
+ - name: app
616
+ in: path
617
+ description: app that defines the job
618
+ example: 'ventrata'
619
+ required: true
620
+ schema:
621
+ type: string
622
+ - name: userId
623
+ in: path
624
+ description: user to run the schedule app against
625
+ example: '536830b6ed19afa44a000002'
626
+ required: true
627
+ schema:
628
+ type: string
629
+ - name: hint
630
+ in: path
631
+ description: a specific token hint to be used
632
+ required: true
633
+ example: 'travelgate-australia'
634
+ schema:
635
+ type: string
636
+ requestBody:
637
+ description: The job parameters
638
+ required: true
639
+ content:
640
+ application/json:
641
+ schema:
642
+ $ref: '#/components/schemas/JobSpec'
643
+ responses:
644
+ 200:
645
+ description: success
646
+ content:
647
+ application/json:
648
+ schema: '#/components/schemas/JobStatus'
649
+ /{app}/{userId}/{jobId}/jobStatus:
650
+ security:
651
+ - bearerAuth: ['app', 'user', 'admin']
652
+ get:
653
+ tags:
654
+ - jobs
655
+ summary: Get the status and/or result from a job
656
+ security:
657
+ - bearerAuth: ['app', 'user', 'admin']
658
+ operationId: getJobStatus
659
+ parameters:
660
+ - name: app
661
+ in: path
662
+ description: app that defines the job
663
+ example: 'ventrata'
664
+ required: true
665
+ schema:
666
+ type: string
667
+ - name: userId
668
+ in: path
669
+ description: user that owns the job
670
+ example: '536830b6ed19afa44a000002'
671
+ required: true
672
+ schema:
673
+ type: string
674
+ - name: jobId
675
+ in: path
676
+ description: jobId to get the status for
677
+ example: '536830b6ed19afa44a000002'
678
+ required: true
679
+ schema:
680
+ type: string
681
+ responses:
682
+ 200:
683
+ description: success
684
+ content:
685
+ application/json:
686
+ schema: '#/components/schemas/JobStatus'
537
687
  /user/{userId}/apps:
538
688
  get:
539
689
  security:
@@ -68,10 +68,10 @@ const frontend = async (req, res, next) => {
68
68
  };
69
69
 
70
70
  const adminOrUser = levels => async (req, res, next) => {
71
- result = await Promise.all(levels.map(
71
+ const result = await Promise.all(levels.map(
72
72
  level => level(req, res, err => err),
73
73
  ));
74
- if (result.some(val => (!Boolean(val)))) {
74
+ if (result.some(val => (!val))) {
75
75
  return next();
76
76
  }
77
77
  return next(result[0]);
package/closeAll.js ADDED
@@ -0,0 +1,6 @@
1
+ const { redisResults, queue } = require('./worker/queue');
2
+
3
+ module.exports = async () => {
4
+ await queue.close();
5
+ await redisResults.quit();
6
+ };
@@ -1,4 +1,4 @@
1
- /* globals describe it expect */
1
+ /* globals beforeAll describe it expect */
2
2
 
3
3
  const chance = require('chance').Chance();
4
4
  const testUtils = require('../../test/utils');
@@ -15,11 +15,18 @@ describe('admin', () => {
15
15
  packageName: `ti2-${appName}`,
16
16
  adminEmail: chance.email(),
17
17
  };
18
- const { doApiPost, doApiGet } = testUtils({
19
- plugins: [appName],
20
- });
21
18
  let appKey;
22
19
  const userId = '536830b6ed19afa44a000002';
20
+ let doApiPost; let
21
+ doApiGet;
22
+ beforeAll(async () => {
23
+ ({
24
+ doApiGet,
25
+ doApiPost,
26
+ } = await testUtils({
27
+ plugins: [appName],
28
+ }));
29
+ });
23
30
  it('should be able to create an app', async () => {
24
31
  ({ value: appKey } = await doApiPost({
25
32
  url: '/app',
@@ -33,11 +40,7 @@ describe('admin', () => {
33
40
  url: '/apps',
34
41
  token: adminKey,
35
42
  });
36
- expect(integrations).toEqual(
37
- expect.arrayContaining([
38
- expect.objectContaining(newApp),
39
- ]),
40
- );
43
+ expect(integrations.find(({ name }) => name === newApp.name)).toBeTruthy();
41
44
  });
42
45
  it('should be able to reset an app\'s key', async () => {
43
46
  const url = `/app/resetAppKey/${newApp.name}`;
@@ -1,9 +1,11 @@
1
- /* globals describe it expect */
2
-
1
+ /* globals beforeAll describe it expect */
3
2
  const chance = require('chance').Chance();
4
3
  const jwt = require('jwt-promise');
4
+ const R = require('ramda');
5
+
5
6
  const testUtils = require('../../test/utils');
6
7
  const slugify = require('../../test/slugify');
8
+ const appController = require('../app');
7
9
 
8
10
  const { env: { adminKey, jwtSecret } } = process;
9
11
 
@@ -16,9 +18,9 @@ describe('app', () => {
16
18
  packageName: `ti2-${appName}`,
17
19
  adminEmail: chance.email(),
18
20
  };
19
- const { doApiPost, doApiGet, doApiPut } = testUtils({
20
- plugins: [appName],
21
- });
21
+ let doApiPost;
22
+ let doApiGet;
23
+ let doApiPut;
22
24
  let appKey;
23
25
  const userId = chance.guid();
24
26
  const apiKey = chance.guid();
@@ -31,6 +33,11 @@ describe('app', () => {
31
33
  payload: { lorem: chance.paragraph() },
32
34
  };
33
35
  let encodedKey;
36
+ beforeAll(async () => {
37
+ ({ doApiPost, doApiGet, doApiPut } = await testUtils({
38
+ plugins: [appName],
39
+ }));
40
+ });
34
41
  it('should create a new app', async () => {
35
42
  ({ value: appKey } = await doApiPost({
36
43
  url: '/app',
@@ -62,6 +69,51 @@ describe('app', () => {
62
69
  });
63
70
  expect(parseInt(value, 10)).toBeGreaterThan(0);
64
71
  });
72
+ describe('jobs', () => {
73
+ it('the scheduled job should have been created for the user', async () => {
74
+ const jobs = R.path(
75
+ ['jobs'],
76
+ await appController.getAppScheduledJobs({
77
+ integrationId: appName,
78
+ userId,
79
+ }),
80
+ );
81
+ expect(Array.isArray(jobs)).toBeTruthy();
82
+ expect(R.head(R.project(['pluginJobId', 'cron'], jobs)))
83
+ .toEqual({
84
+ pluginJobId: 'dailyReport',
85
+ cron: '0 9 * * *',
86
+ });
87
+ });
88
+ it.todo('if a new version of the plugin has a different set of scheduled tasks, they shoul dbe re-syncjed');
89
+ let jobId;
90
+ it('should be able to run a job', async () => {
91
+ let status;
92
+ ({ jobId, status } = await doApiPost({
93
+ url: `/${appName}/${userId}/jobRun`,
94
+ token: appKey,
95
+ payload: {
96
+ payload: {
97
+ method: 'dailyReport',
98
+ },
99
+ },
100
+ }));
101
+ expect(jobId).toBeTruthy();
102
+ expect(status).toBeTruthy();
103
+ });
104
+ it('should be able to get the status of a ran job', async () => {
105
+ await global.sleep(5e3);
106
+ const url = `/${appName}/${userId}/${jobId}/jobStatus`;
107
+ const jobStatus = await doApiGet({
108
+ url,
109
+ token: appKey,
110
+ payload: {},
111
+ });
112
+ expect(jobStatus.jobId).toBeTruthy();
113
+ expect(jobStatus.status).toBe('success');
114
+ expect(jobStatus.result).toEqual({ someVal: true });
115
+ }, 6e3);
116
+ });
65
117
  it('should be able to get a list of all tokens related to the app', async () => {
66
118
  const { userAppKeys } = await doApiGet({
67
119
  url: `/app/tokens/${appName}`,
@@ -1,4 +1,4 @@
1
- /* globals describe it expect */
1
+ /* globals beforeAll describe it expect */
2
2
 
3
3
  const chance = require('chance').Chance();
4
4
  const testUtils = require('../../test/utils');
@@ -22,13 +22,19 @@ describe('user: booking search', () => {
22
22
  tokenHint: 'testingToken',
23
23
  token,
24
24
  };
25
- const {
26
- doApiDelete,
27
- doApiGet,
28
- doApiPost,
29
- plugins,
30
- } = testUtils({
31
- plugins: [newApp.name],
25
+ let doApiDelete;
26
+ let doApiGet;
27
+ let doApiPost;
28
+ let plugins;
29
+ beforeAll(async () => {
30
+ ({
31
+ doApiDelete,
32
+ doApiGet,
33
+ doApiPost,
34
+ plugins,
35
+ } = await testUtils({
36
+ plugins: [newApp.name],
37
+ }));
32
38
  });
33
39
 
34
40
  let userToken;
@@ -39,13 +45,17 @@ describe('user: booking search', () => {
39
45
  token: adminKey,
40
46
  payload: { tokenHint: 'testingToken' },
41
47
  });
42
- } catch {}
48
+ } catch (err) {
49
+ console.debug(err);
50
+ }
43
51
  try {
44
52
  await doApiDelete({
45
53
  url: '/app/travelgate',
46
54
  token: adminKey,
47
55
  });
48
- } catch {}
56
+ } catch (err) {
57
+ console.debug(err);
58
+ }
49
59
  });
50
60
  it('create the travelgate app', async () => {
51
61
  const { value: apiKey } = await doApiPost({
@@ -1,8 +1,13 @@
1
- /* global describe, it, expect */
2
-
3
- const { doApiGet } = require('../../test/utils')();
1
+ /* global beforeAll, describe, it, expect */
2
+ const testUtils = require('../../test/utils');
4
3
 
5
4
  describe('ping', () => {
5
+ let doApiGet;
6
+ beforeAll(async () => {
7
+ ({
8
+ doApiGet,
9
+ } = await testUtils());
10
+ });
6
11
  it('should get a server pong', async () => {
7
12
  const response = await doApiGet({
8
13
  url: '/ping',
@@ -15,9 +15,8 @@ describe('user', () => {
15
15
  packageName: `ti2-${appName}`,
16
16
  adminEmail: chance.email(),
17
17
  };
18
- const { doApiPost, doApiGet, doApiDelete } = testUtils({
19
- plugins: [appName],
20
- });
18
+ let doApiGet; let doApiPost; let
19
+ doApiDelete;
21
20
  let appKey;
22
21
  const userId = chance.guid();
23
22
  let apiKey = chance.guid();
@@ -27,6 +26,13 @@ describe('user', () => {
27
26
  apiKey,
28
27
  };
29
28
  beforeAll(async () => {
29
+ ({
30
+ doApiDelete,
31
+ doApiGet,
32
+ doApiPost,
33
+ } = await testUtils({
34
+ plugins: [appName],
35
+ }));
30
36
  // create an App
31
37
  ({ value: appKey } = await doApiPost({
32
38
  url: '/app',
@@ -5,8 +5,15 @@ const { Umzug, SequelizeStorage } = require('umzug');
5
5
  const path = require('path');
6
6
  const Sequelize = require('sequelize');
7
7
  const fs = require('fs').promises;
8
+ const bb = require('bluebird');
9
+ const R = require('ramda');
8
10
 
9
11
  const sqldb = require('../models');
12
+ const {
13
+ queue,
14
+ addJob,
15
+ jobStatus,
16
+ } = require('../worker/queue');
10
17
 
11
18
  const { env: { jwtSecret } } = process;
12
19
 
@@ -48,6 +55,61 @@ const createAppToken = async (req, res, next) => {
48
55
  });
49
56
  if (userAppKeyDup) await userAppKeyDup.destroy();
50
57
  const newAppKey = await sqldb.UserAppKey.create(payload);
58
+ // create any cronjobs related to the app
59
+ await bb.each(req.app.plugins, async plugin => {
60
+ if (Array.isArray(plugin.jobs)) {
61
+ await bb.each(plugin.jobs.filter(job => Boolean(job.cron)), async job => {
62
+ const where = {
63
+ pluginName: plugin.name,
64
+ pluginJobId: job.method,
65
+ userId,
66
+ hint,
67
+ cron: job.cron,
68
+ };
69
+ const existing = await sqldb.CronJobs.findOne({
70
+ where: R.omit(['cron'], where),
71
+ });
72
+ const jobPayload = {
73
+ ...where,
74
+ ...job.payload,
75
+ };
76
+ const jobParams = {
77
+ ...(job.cron ? {
78
+ repeat: {
79
+ cron: job.cron,
80
+ },
81
+ } : {}),
82
+ ...(job.params || {}),
83
+ removeOnComplete: false,
84
+ };
85
+ let bullJobId;
86
+ if (existing) {
87
+ const bullJob = await queue.getJob(job.bullJobId);
88
+ if (!bullJob) {
89
+ bullJobId = await addJob(jobPayload, jobParams);
90
+ existing.bullJobId = bullJobId;
91
+ await existing.save();
92
+ } else {
93
+ // make sure the cron is the same
94
+ const bullCron = R.path(
95
+ ['opts', 'repeat', 'cron'],
96
+ await queue.getJob(bullJobId),
97
+ );
98
+ if (bullCron !== job.cron) {
99
+ await queue.removeJobs(bullJobId);
100
+ bullJobId = await addJob(jobPayload, jobParams);
101
+ existing.bullJobId = bullJobId;
102
+ await existing.save();
103
+ }
104
+ }
105
+ } else {
106
+ bullJobId = await addJob(jobPayload, jobParams);
107
+ await sqldb.CronJobs.create({ ...where, bullJobId });
108
+ }
109
+ });
110
+ }
111
+ });
112
+
51
113
  return res.json({ value: newAppKey.get('id').toString() });
52
114
  } catch (err) {
53
115
  return next(err);
@@ -133,10 +195,70 @@ const migrateApp = async ({ integrationId, action }) => {
133
195
  throw Error('No recognized action');
134
196
  };
135
197
 
198
+ const getAppScheduledJobs = async ({
199
+ integrationId,
200
+ userId,
201
+ hint,
202
+ }) => {
203
+ const where = R.reject(R.isNil)({
204
+ pluginName: integrationId,
205
+ userId,
206
+ hint,
207
+ });
208
+ const jobs = await sqldb.CronJobs.findAll({
209
+ where,
210
+ raw: true,
211
+ });
212
+ return { jobs };
213
+ };
214
+
215
+ const runAppJob = async (req, res, next) => {
216
+ const {
217
+ body: {
218
+ payload,
219
+ jobParams,
220
+ },
221
+ params: {
222
+ app: pluginName,
223
+ hint,
224
+ userId,
225
+ },
226
+ } = req;
227
+ try {
228
+ const bullJobId = await addJob({
229
+ ...payload,
230
+ pluginName,
231
+ hint,
232
+ userId,
233
+ }, jobParams);
234
+ assert(bullJobId);
235
+ return res.json(await jobStatus({ jobId: bullJobId }));
236
+ } catch (err) {
237
+ return next(err);
238
+ }
239
+ };
240
+
241
+ const getJobStatus = async (req, res, next) => {
242
+ const {
243
+ params: {
244
+ jobId,
245
+ },
246
+ } = req;
247
+ try {
248
+ const returnValue = await jobStatus({ jobId });
249
+ return res.json(returnValue);
250
+ } catch (err) {
251
+ return next(err);
252
+ }
253
+ };
254
+
136
255
  module.exports = {
137
256
  createAppToken,
138
257
  deleteAppToken,
258
+ getAppScheduledJobs,
259
+ getJobStatus,
139
260
  jwtEncode,
140
261
  listAppTokens,
141
262
  migrateApp,
263
+ runAppJob,
142
264
  };
@@ -20,7 +20,8 @@ const ping = (_req, res, next) => {
20
20
  });
21
21
  } catch (err) {
22
22
  return next(err);
23
- } };
23
+ }
24
+ };
24
25
 
25
26
  module.exports = {
26
27
  ping,
@@ -1,11 +1,7 @@
1
1
  // controllers/user.js
2
- const { omit, flatten } = require('ramda');
3
- const assert = require('assert');
4
- const Promise = require('bluebird');
5
- const moment = require('moment');
2
+ const { omit } = require('ramda');
6
3
 
7
- const { UserAppKey, Sequelize: { Op } } = require('../models');
8
- // const { load } = require('../../plugins/index.js');
4
+ const { UserAppKey } = require('../models');
9
5
 
10
6
  const userAppList = async (req, res, next) => {
11
7
  const { params: { userId } } = req;
package/index.js CHANGED
@@ -5,6 +5,9 @@ const swaggerUi = require('swagger-ui-express');
5
5
  const yaml = require('js-yaml');
6
6
  const fs = require('fs');
7
7
  const { pickBy } = require('ramda');
8
+ const bb = require('bluebird');
9
+ const { Op } = require('sequelize');
10
+ const R = require('ramda');
8
11
 
9
12
  const schema = yaml.load(fs.readFileSync(`${__dirname}/api.yml`));
10
13
 
@@ -14,18 +17,20 @@ const adminController = require('./controllers/admin');
14
17
  const appController = require('./controllers/app');
15
18
  const userController = require('./controllers/user');
16
19
  const bookingsController = require('./controllers/bookings');
20
+ const { Integration } = require('./models');
17
21
 
18
- module.exports = ({
22
+ module.exports = async ({
19
23
  apiDocs = true,
20
24
  elasticLogIndex = 'apilog_ti2',
21
25
  elasticLogsClient,
22
26
  plugins: pluginsParam = {},
23
27
  port: portParam,
24
28
  startServer = true,
29
+ worker = false,
25
30
  }) => {
26
31
  const port = portParam || process.env.PORT || 10010;
27
- const app = express();
28
- const plugins = Object.entries(pluginsParam).map(([pluginName, Plugin]) => {
32
+ // create the plugin instances
33
+ const plugins = await bb.map(Object.entries(pluginsParam), async ([pluginName, Plugin]) => {
29
34
  // pass all env variables
30
35
  const pluginEnv = pickBy(
31
36
  (_val, key) => key.substring(0, `ti2_${pluginName}`.length)
@@ -37,9 +42,34 @@ module.exports = ({
37
42
  const nuName = attr.replace(/_/g, '-').replace(`ti2-${pluginName}-`, '');
38
43
  params[nuName] = value;
39
44
  });
40
- return new Plugin({ name: pluginName, ...params });
45
+ const pluginInstance = await new Plugin({ name: pluginName, ...params });
46
+ return pluginInstance;
47
+ });
48
+ if (worker) {
49
+ return require('./worker/index')({ plugins });
50
+ }
51
+ // make sure all plugins have a DB entry
52
+ const pluginNames = plugins.map(R.prop('name'));
53
+ const matchedIntegrations = await Integration.findAll({
54
+ attributes: ['name'],
55
+ where: {
56
+ name: {
57
+ [Op.in]: pluginNames,
58
+ },
59
+ },
60
+ raw: true,
41
61
  });
42
- // console.log({ plugins });
62
+ const missingIntegrations = R.difference(pluginNames, matchedIntegrations.map(R.prop('name')));
63
+ if (missingIntegrations.length > 0) {
64
+ // need to crete the missing integrations
65
+ await Integration.bulkCreate(missingIntegrations.map(name => ({
66
+ name,
67
+ packageName: `ti2-${name}`,
68
+ adminEmail: `ti2+${name}@localhost.local`,
69
+ })));
70
+ }
71
+ // create the API Web server
72
+ const app = express();
43
73
  const api = {
44
74
  ...pingController,
45
75
  ...adminController,
@@ -141,9 +171,12 @@ module.exports = ({
141
171
  connect(app);
142
172
  app.use(middleware.mock());
143
173
  // global error Handling
144
- app.use((err, req, res, next) => {
174
+ app.use((err, req, res) => {
145
175
  // console.log(req.headers.['X-Request-Id'], err);
146
176
  res.status(err.status || 500);
177
+ if ((process.env.JEST_WORKER_ID)) {
178
+ console.debug(err);
179
+ }
147
180
  return res.json({
148
181
  message: err.message || 'Internal Error',
149
182
  });
@@ -156,8 +189,11 @@ module.exports = ({
156
189
  // first create a generic "terminator"
157
190
  const terminator = sig => {
158
191
  if (typeof sig === 'string') {
159
- console.log('%s: Received %s - terminating ti2 ...',
160
- Date(Date.now()), sig);
192
+ console.log(
193
+ '%s: Received %s - terminating ti2 ...',
194
+ Date(Date.now()),
195
+ sig,
196
+ );
161
197
  process.exit(1);
162
198
  }
163
199
  console.log('%s: Node server stopped.', Date(Date.now()));
package/jest.setup.js ADDED
@@ -0,0 +1,11 @@
1
+ /* global afterAll */
2
+
3
+ const closeAll = require('./closeAll');
4
+
5
+ afterAll(async () => {
6
+ await closeAll();
7
+ });
8
+
9
+ global.sleep = (ms = 300) => new Promise(r => {
10
+ setTimeout(r, ms);
11
+ });
@@ -1,4 +1,3 @@
1
-
2
1
  module.exports = {
3
2
  up: (queryInterface, Sequelize) => queryInterface.addColumn(
4
3
  'Integrations',
@@ -3,8 +3,8 @@ module.exports = {
3
3
  type: Sequelize.TEXT,
4
4
  allowNull: false,
5
5
  }),
6
- down: (queryInterface, Sequelize) => queryInterface.changeColumn('UserAppKeys', 'appKey', {
6
+ down: (queryInterface, Sequelize) => queryInterface.changeColumn('UserAppKeys', 'appKey', {
7
7
  type: Sequelize.STRING,
8
8
  allowNull: false,
9
- })
9
+ }),
10
10
  };
@@ -0,0 +1,41 @@
1
+ module.exports = {
2
+ up: (queryInterface, Sequelize) => queryInterface
3
+ .createTable('CronJobs', {
4
+ pluginName: {
5
+ allowNull: false,
6
+ primaryKey: true,
7
+ type: Sequelize.STRING,
8
+ },
9
+ userId: {
10
+ allowNull: false,
11
+ primaryKey: true,
12
+ type: Sequelize.STRING,
13
+ },
14
+ hint: {
15
+ allowNull: false,
16
+ primaryKey: true,
17
+ type: Sequelize.STRING,
18
+ },
19
+ pluginJobId: {
20
+ allowNull: false,
21
+ type: Sequelize.STRING,
22
+ },
23
+ bullJobId: {
24
+ allowNull: false,
25
+ type: Sequelize.STRING,
26
+ },
27
+ cron: {
28
+ allowNull: false,
29
+ type: Sequelize.STRING,
30
+ },
31
+ createdAt: {
32
+ allowNull: false,
33
+ type: Sequelize.DATE,
34
+ },
35
+ updatedAt: {
36
+ allowNull: false,
37
+ type: Sequelize.DATE,
38
+ },
39
+ }),
40
+ down: queryInterface => queryInterface.dropTable('CronJobs'),
41
+ };
@@ -0,0 +1,31 @@
1
+ const Sequelize = require('sequelize');
2
+ const db = require('./db');
3
+
4
+ const CronJobs = db.define('CronJobs', {
5
+ pluginName: {
6
+ type: Sequelize.STRING,
7
+ primaryKey: true,
8
+ },
9
+ userId: {
10
+ type: Sequelize.STRING,
11
+ primaryKey: true,
12
+ },
13
+ hint: {
14
+ type: Sequelize.STRING,
15
+ primaryKey: true,
16
+ },
17
+ pluginJobId: {
18
+ type: Sequelize.STRING,
19
+ primaryKey: true,
20
+ },
21
+ bullJobId: {
22
+ type: Sequelize.STRING,
23
+ allowNull: false,
24
+ },
25
+ cron: {
26
+ type: Sequelize.STRING,
27
+ allowNull: false,
28
+ },
29
+ }, {});
30
+
31
+ module.exports = CronJobs;
package/models/index.js CHANGED
@@ -10,6 +10,7 @@ fs
10
10
  .readdirSync(__dirname)
11
11
  .filter(file => (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'))
12
12
  .forEach(file => {
13
+ // eslint-disable-next-line import/no-dynamic-require, global-require
13
14
  const model = require(path.join(__dirname, file));
14
15
  models[model.name] = model;
15
16
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -8,21 +8,28 @@
8
8
  "sequelize": "sequelize-cli",
9
9
  "doc": "jsdoc -c jsdoc.conf.json -u tutorials"
10
10
  },
11
+ "jest": {
12
+ "setupFilesAfterEnv": [
13
+ "./jest.setup.js"
14
+ ]
15
+ },
11
16
  "repository": {
12
17
  "type": "git",
13
18
  "url": "git+https://github.com/ti2travel/ti2.git"
14
19
  },
15
20
  "bin": "./cli.js",
16
21
  "author": "",
17
- "license": "GPL-3",
22
+ "license": "GPL-3.0",
18
23
  "bugs": {
19
24
  "url": "https://github.com/ti2travel/ti2/issues"
20
25
  },
21
26
  "homepage": "https://github.com/ti2travel/ti2#readme",
22
27
  "dependencies": {
23
28
  "bluebird": "^3.7.2",
29
+ "bull": "^4.8.2",
24
30
  "commander": "^9.2.0",
25
31
  "express": "^4.17.2",
32
+ "ioredis": "^5.0.4",
26
33
  "js-yaml": "^4.1.0",
27
34
  "jsonwebtoken": "^8.5.1",
28
35
  "jwt-promise": "^1.0.0",
@@ -34,17 +41,18 @@
34
41
  "swagger-express-middleware": "3.0.0-alpha.5",
35
42
  "swagger-routes-express": "3.3.1",
36
43
  "swagger-ui-express": "4.3.0",
44
+ "throng": "^5.0.0",
37
45
  "umzug": "^3.1.1",
38
46
  "uninstall": "^0.0.0"
39
47
  },
40
48
  "devDependencies": {
49
+ "@salvador/minami": "^1.2.3",
41
50
  "chance": "^1.1.8",
42
51
  "eslint": "^8.7.0",
43
52
  "eslint-config-airbnb-base": "^15.0.0",
44
53
  "eslint-plugin-import": "^2.25.4",
45
54
  "jest": "^27.4.7",
46
55
  "jsdoc": "^3.6.10",
47
- "minami": "git+https://github.com/MyNameReallySux/minami.git#sort-data",
48
56
  "supertest": "^6.2.2"
49
57
  }
50
58
  }
package/test/plugin.js CHANGED
@@ -1,4 +1,15 @@
1
1
  /* global jest, expect */
2
+ /* eslint class-methods-use-this: "off", max-len: "off" */
3
+
4
+ const jestPlugin = (() => {
5
+ if (typeof jest === 'undefined') {
6
+ return {
7
+ fn: fn => fn,
8
+ };
9
+ }
10
+ return jest;
11
+ })();
12
+
2
13
  const chance = require('chance').Chance();
3
14
 
4
15
  class Plugin {
@@ -15,16 +26,16 @@ class Plugin {
15
26
  this[attr] = value;
16
27
  });
17
28
  // mock implementations
18
- this.validateToken = jest.fn(() => true);
19
- this.getProfile = jest.fn(() => {});
20
- this.updateProfile = jest.fn(() => {});
21
- this.getProduct = jest.fn(() => ({ products: [] }));
22
- this.getProducts = jest.fn(() => true);
23
- this.createLocation = jest.fn(() => ({ locationId: chance.guid() }));
24
- this.updateLocation = jest.fn(() => true);
25
- this.searchBooking = jest.fn(() => ({ bookings: [] }));
26
- this.searchProducts = jest.fn(() => ({ bookings: [], products: [] }));
27
- this.searchAvailability = jest.fn(({
29
+ this.validateToken = jestPlugin.fn(() => true);
30
+ this.getProfile = jestPlugin.fn(() => {});
31
+ this.updateProfile = jestPlugin.fn(() => {});
32
+ this.getProduct = jestPlugin.fn(() => ({ products: [] }));
33
+ this.getProducts = jestPlugin.fn(() => true);
34
+ this.createLocation = jestPlugin.fn(() => ({ locationId: chance.guid() }));
35
+ this.updateLocation = jestPlugin.fn(() => true);
36
+ this.searchBooking = jestPlugin.fn(() => ({ bookings: [] }));
37
+ this.searchProducts = jestPlugin.fn(() => ({ bookings: [], products: [] }));
38
+ this.searchAvailability = jestPlugin.fn(({
28
39
  token,
29
40
  payload: {
30
41
  travelDateStart,
@@ -40,9 +51,23 @@ class Plugin {
40
51
  expect(Array.isArray(occupancies)).toBeTruthy();
41
52
  return { availability: [{ id: chance.guid() }] };
42
53
  });
54
+ this.searchQuote = jestPlugin.fn(() => ({ quote: [{ id: chance.guid() }] }));
55
+ this.createBooking = jestPlugin.fn(() => {});
56
+
57
+ /**
58
+ * Background and schedule Jos
59
+ */
60
+ this.jobs = [{
61
+ method: 'dailyReport',
62
+ payload: {
63
+ action: 'report',
64
+ },
65
+ cron: '0 9 * * *',
66
+ }];
43
67
 
44
- this.searchQuote = jest.fn(() => ({ quote: [{ id: chance.guid() }] }));
45
- this.createBooking = jest.fn(() => {});
68
+ this.dailyReport = jestPlugin.fn(() => ({
69
+ someVal: true,
70
+ }));
46
71
  }
47
72
 
48
73
  /**
@@ -258,7 +283,7 @@ class Plugin {
258
283
  return true;
259
284
  }
260
285
 
261
- searchHotelBooking = jest.fn(() => ({ bookings: [] }));
286
+ searchHotelBooking = jestPlugin.fn(() => ({ bookings: [] }));
262
287
 
263
288
  /**
264
289
  * Booking Object
@@ -363,6 +388,7 @@ class Plugin {
363
388
  searchAvailability() {
364
389
  // return (args);
365
390
  }
391
+
366
392
  /**
367
393
  * Retrieve an availability calendar over a date range
368
394
  * @async
@@ -379,6 +405,7 @@ class Plugin {
379
405
  availabilityCalndar() {
380
406
  // return (args);
381
407
  }
408
+
382
409
  /**
383
410
  * A quote return spec
384
411
  * @typedef {Object} Quote
package/test/utils.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const chance = require('chance').Chance();
4
4
  const request = require('supertest');
5
5
  const assert = require('assert');
6
+
6
7
  const appReq = require('../index');
7
8
  const Plugin = require('./plugin');
8
9
  const slugify = require('./slugify');
@@ -10,12 +11,10 @@ const sqldb = require('../models/db');
10
11
 
11
12
  const { env: { adminKey } } = process;
12
13
 
13
- const timeout = ms => (new Promise(resolve => setTimeout(resolve, ms)));
14
-
15
14
  afterAll(async () => {
16
15
  await sqldb.connectionManager.close();
17
16
  });
18
- module.exports = (appParams = {}) => {
17
+ module.exports = async (appParams = {}) => {
19
18
  const plugins = (() => {
20
19
  if (Array.isArray(appParams.plugins)) {
21
20
  const retVal = {};
@@ -28,7 +27,7 @@ module.exports = (appParams = {}) => {
28
27
  mockName: Plugin,
29
28
  };
30
29
  })();
31
- const app = appReq({
30
+ const app = await appReq({
32
31
  startServer: false,
33
32
  ...appParams,
34
33
  plugins,
@@ -104,7 +103,6 @@ module.exports = (appParams = {}) => {
104
103
  doApiPost,
105
104
  doApiPut,
106
105
  slugify,
107
- timeout,
108
106
  plugins: app.plugins,
109
107
  };
110
108
  };
package/test/worker.js ADDED
@@ -0,0 +1,5 @@
1
+ // tests only file, should not be execute by itself
2
+
3
+ const worker = require('../worker/index')({ plugins: [] });
4
+
5
+ worker.then(() => {});
@@ -23,6 +23,7 @@ User + app integration credentials can be added after the app has been added to
23
23
  A MySQl instance is required and the following environment variables are required:
24
24
 
25
25
  - DB_URL (mysql connection in url format)
26
+ - REDIS_URL (a redis database url to store the background queue)
26
27
  - dbCryptoKey (integration details encription key, should be a 32 chars base64 encoded random string)
27
28
  - adminKey (a key for admin related requests)
28
29
  - jwtKey (a key to encrypt user sessions request)
@@ -31,6 +32,7 @@ A MySQl instance is required and the following environment variables are require
31
32
 
32
33
  ```
33
34
  DB_URL=mysql://root:@mysqlserver/ti2development
35
+ REDIS_URL=redis://redis:6379
34
36
  dbCryptoKey=cG9VYmJyQ2tlSDVHeXQ4RUN4VlNieEJRejNYWDlZU0g=
35
37
  jwtSecret=C8k0mrHVfWVTP7pIoZEHRrvFgTULhw3E4swDq1aoDH4P
36
38
  frontendKey=IpOiLahrGQxsQimwoK7Z
@@ -61,21 +63,57 @@ then just create an entry file like this :
61
63
 
62
64
  ```javascript
63
65
  // index.js
64
- const ti2 = require('ti2')({
65
- plugins: {
66
- travelgate: require('ti2-travelgate'),
67
- ventrata: require('ti2-ventrata'),
68
- tourconnect: require('ti2-tourconnect'),
69
- },
70
- });
71
- ```
72
66
 
73
- and stat the server using node:
67
+ module.exports = (async () => {
68
+ const ti2 = await require('ti2')({
69
+ plugins: {
70
+ ventrata: require('ti2-ventrata'),
71
+ travelgate: require('ti2-travelgate'),
72
+ tourconnect: require('ti2-tourconnect'),
73
+ },
74
+ });
75
+ return ti2;
76
+ })();
74
77
 
75
78
  ```
79
+
80
+ and start the server:
81
+
82
+ ```bash
76
83
  $ node index.js
77
84
  ```
78
85
 
86
+ in order to support the background job queue (required by some plugins) you should consider the following example:
87
+
88
+ ```javascript
89
+ const plugins = {
90
+ ventrata: require('ti2-ventrata'),
91
+ travelgate: require('ti2-travelgate'),
92
+ tourconnect: require('ti2-tourconnect'),
93
+ };
94
+
95
+ module.exports = (async () => {
96
+ if (process.argv.indexOf('worker') > 0) {
97
+ const worker = await require('ti2')({
98
+ plugins,
99
+ worker: true,
100
+ });
101
+ return worker;
102
+ } else {
103
+ const ti2 = await require('ti2')({
104
+ plugins,
105
+ });
106
+ return ti2;
107
+ }
108
+ })();
109
+ ```
110
+
111
+ and start a background worker (on a second terminal), required for some plugins:
112
+
113
+ ```bash
114
+ $ node index.js worker
115
+ ```
116
+
79
117
  ## API logging
80
118
 
81
119
  Currently logging is supported via elastic search, an elasticLogClient value can ben passed down, example:
@@ -87,12 +125,26 @@ const elasticLogsClient = (() => {
87
125
  return new Client({ node: elasticLogs });
88
126
  })();
89
127
 
90
- const ti2 = require('ti2')({
91
- elasticLogsClient,
92
- plugins: {
93
- ventrata: require('ti2-ventrata'),
94
- travelgate: require('ti2-travelgate'),
95
- tourconnect: require('ti2-tourconnect'),
96
- },
97
- });
128
+ const plugins = {
129
+ ventrata: require('ti2-ventrata'),
130
+ travelgate: require('ti2-travelgate'),
131
+ tourconnect: require('ti2-tourconnect'),
132
+ };
133
+
134
+ module.exports = (async () => {
135
+ if (process.argv.indexOf('worker') > 0) {
136
+ const worker = await require('ti2')({
137
+ elasticLogsClient,
138
+ plugins,
139
+ worker: true,
140
+ });
141
+ return worker;
142
+ } else {
143
+ const ti2 = await require('ti2')({
144
+ elasticLogsClient,
145
+ plugins,
146
+ });
147
+ return ti2;
148
+ }
149
+ })();
98
150
  ```
@@ -0,0 +1,53 @@
1
+ const throng = require('throng');
2
+ require('util').inspect.defaultOptions.depth = null;
3
+ const fakePlugin = require('../test/plugin');
4
+
5
+ const { queue, saveResult } = require('./queue');
6
+
7
+ const workers = process.env.WEB_CONCURRENCY || 2;
8
+
9
+ const maxJobsPerWorker = 1;
10
+
11
+ const worker = ({ plugins: pluginsParam }) => (id, disconnect) => {
12
+ console.log(`Started worker ${id}`);
13
+
14
+ const bye = () => {
15
+ console.log(`Worker ${id} exiting`);
16
+ disconnect();
17
+ process.exit(0);
18
+ };
19
+
20
+ process.on('SIGTERM', bye);
21
+ process.on('SIGUSR2', bye);
22
+ process.on('SIGINT', bye);
23
+
24
+ queue.process(maxJobsPerWorker, async job => {
25
+ const {
26
+ data: {
27
+ pluginName,
28
+ method,
29
+ userId,
30
+ inTesting,
31
+ },
32
+ id: jobId,
33
+ data: params,
34
+ } = job;
35
+ console.log(`job ${jobId} > running ${pluginName}:${method} for ${userId}`);
36
+ const plugins = await (async () => {
37
+ if (inTesting) {
38
+ return [...pluginsParam, await new fakePlugin({ name: pluginName })];
39
+ }
40
+ return pluginsParam;
41
+ })();
42
+ const thePlugin = plugins.find(({ name }) => name === pluginName);
43
+ const resultValue = await thePlugin[method](params);
44
+ console.log(`job ${jobId} > completed`);
45
+ await saveResult({ id: jobId, resultValue });
46
+ });
47
+ };
48
+
49
+ module.exports = async args => throng({
50
+ workers,
51
+ worker: worker(args),
52
+ signals: ['SIGUSR2', 'SIGTERM', 'SIGINT'],
53
+ });
@@ -0,0 +1,88 @@
1
+ const Queue = require('bull');
2
+ const Redis = require('ioredis');
3
+ const R = require('ramda');
4
+
5
+ const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
6
+ const itemsTTL = 3 * 60 * 60; // 3 hours
7
+
8
+ const queue = new Queue('work', `${REDIS_URL}/0`);
9
+ const redisResults = new Redis(`${REDIS_URL}/1`);
10
+
11
+ const getPending = async () => {
12
+ const pending = await queue.count();
13
+ const running = await queue.getActiveCount();
14
+ return pending + running;
15
+ };
16
+
17
+ const allDone = async () => {
18
+ const left1 = await getPending();
19
+ if (left1 === 0) return true;
20
+ return (new Promise(resolve => {
21
+ queue.on('global:completed', async () => {
22
+ const left = await getPending();
23
+ if (left === 0) resolve();
24
+ });
25
+ }));
26
+ };
27
+
28
+ queue.on('failed', (job, err) => {
29
+ console.log(`job ${job.id} failed`, err);
30
+ });
31
+
32
+ const addJob = async (payload, paramsParam) => {
33
+ const params = paramsParam || {};
34
+ const inTesting = Boolean(process.env.JEST_WORKER_ID);
35
+ const id = R.path(['id'], await queue.add({
36
+ ...payload,
37
+ inTesting,
38
+ }, {
39
+ removeOnComplete: true,
40
+ ...params,
41
+ }));
42
+ return id;
43
+ };
44
+
45
+ const saveResult = async ({ id, resultValue }) => {
46
+ await redisResults.set(id, JSON.stringify(resultValue), 'EX', itemsTTL);
47
+ };
48
+
49
+ const jobStatus = async ({ jobId }) => {
50
+ try {
51
+ const job = await queue.getJob(jobId);
52
+ if (!job) {
53
+ // check if it is done
54
+ const result = JSON.parse(await redisResults.get(jobId));
55
+ // await global.sleep();
56
+ if (result) {
57
+ return {
58
+ jobId,
59
+ status: 'success',
60
+ result,
61
+ };
62
+ }
63
+ return { // 404
64
+ jobId,
65
+ status: 'failed',
66
+ };
67
+ }
68
+ const state = await job.getState();
69
+ return {
70
+ jobId,
71
+ status: state.toLowerCase(),
72
+ };
73
+ } catch (err) {
74
+ console.log(`could not get job status for ${jobId}`, err);
75
+ return { // 500
76
+ jobId,
77
+ status: 'failed',
78
+ };
79
+ }
80
+ };
81
+ module.exports = {
82
+ addJob,
83
+ allDone,
84
+ jobStatus,
85
+ queue,
86
+ saveResult,
87
+ redisResults,
88
+ };