ti2 1.0.94 → 1.0.95

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.
@@ -1,18 +1,33 @@
1
- const jwt = require('jwt-promise');
1
+ const jwt = require('jsonwebtoken');
2
2
  const { omit } = require('ramda');
3
3
  const assert = require('assert');
4
4
  const { Umzug, SequelizeStorage } = require('umzug');
5
5
  const path = require('path');
6
6
  const Sequelize = require('sequelize');
7
- const fs = require('fs').promises;
7
+ const fs = require('fs'); // Changed to synchronous fs for initial load
8
+ const yaml = require('js-yaml');
8
9
  const bb = require('bluebird');
9
10
  const R = require('ramda');
10
11
 
12
+ // Load OpenAPI schema
13
+ let openApiSchema = null;
14
+ try {
15
+ const schemaPath = path.join(__dirname, '..', 'api.yml'); // Assuming api.yml is in the parent directory of controllers
16
+ const schemaFile = fs.readFileSync(schemaPath, 'utf8');
17
+ openApiSchema = yaml.load(schemaFile);
18
+ } catch (e) {
19
+ console.error('Failed to load OpenAPI schema:', e);
20
+ // Handle error appropriately, perhaps by preventing the app from starting
21
+ // or by using a default empty schema to prevent crashes later.
22
+ // For now, we'll let it potentially crash if openApiSchema is null and accessed.
23
+ }
24
+
11
25
  const sqldb = require('../models');
12
26
  const {
13
27
  queue,
14
28
  addJob,
15
29
  jobStatus,
30
+ removeJob,
16
31
  } = require('../worker/queue');
17
32
 
18
33
  const { env: { jwtSecret } } = process;
@@ -96,7 +111,7 @@ const createAppToken = async (req, res, next) => {
96
111
  if (userAppKeyDup) await userAppKeyDup.destroy();
97
112
  const newAppKey = await sqldb.UserAppKey.create(payload);
98
113
  // create any cronjobs related to the app
99
- await bb.each(req.app.plugins, async plugin => {
114
+ await bb.each(req.app.plugins, async plugin => {
100
115
  if (Array.isArray(plugin.jobs)) {
101
116
  const validJobs = plugin.jobs.filter(job => Boolean(job.cron) && Boolean(job.method));
102
117
  await bb.each(validJobs, async job => {
@@ -151,6 +166,7 @@ const createAppToken = async (req, res, next) => {
151
166
  }
152
167
  });
153
168
 
169
+
154
170
  return res.json({ value: newAppKey.get('id').toString() });
155
171
  } catch (err) {
156
172
  return next(err);
@@ -297,7 +313,7 @@ const migrateApp = async ({ integrationId, action }) => {
297
313
  'migrations',
298
314
  );
299
315
  try {
300
- await fs.access(migrationsPath);
316
+ await fs.promises.access(migrationsPath);
301
317
  } catch (err) {
302
318
  throw Error(`Could not find any migrations for ${integrationId} on ${migrationsPath}`);
303
319
  }
@@ -332,12 +348,10 @@ const migrateApp = async ({ integrationId, action }) => {
332
348
  const getAppScheduledJobs = async ({
333
349
  integrationId,
334
350
  userId,
335
- hint,
336
351
  }) => {
337
352
  const where = R.reject(R.isNil)({
338
353
  pluginName: integrationId,
339
354
  userId,
340
- hint,
341
355
  });
342
356
  const jobs = await sqldb.CronJobs.findAll({
343
357
  where,
@@ -487,6 +501,252 @@ const getAppToken = async (req, res, next) => {
487
501
  return res.json({ token: await userAppKey.token });
488
502
  };
489
503
 
504
+ const createCronjob = async (req, res, next) => {
505
+ const {
506
+ params: {
507
+ userId,
508
+ },
509
+ body: {
510
+ method,
511
+ url,
512
+ cron,
513
+ callbackUrl,
514
+ body,
515
+ removeOnComplete,
516
+ },
517
+ } = req;
518
+
519
+ let transaction;
520
+
521
+ try {
522
+ // Validate required fields
523
+ if (!method || !url || !cron) {
524
+ const error = new Error('method, url, and cron are required fields');
525
+ error.status = 400;
526
+ throw error;
527
+ }
528
+
529
+ // Validate URL path
530
+ const validUrlPattern = /^\/[\w\-\/\.]+$/;
531
+ if (!validUrlPattern.test(url)) {
532
+ const error = new Error('Invalid URL path');
533
+ error.status = 400;
534
+ throw error;
535
+ }
536
+
537
+ // Helper function to find a matching path in the OpenAPI schema
538
+ const findMatchingOpenApiPath = (requestUrl, schemaPaths) => {
539
+ if (!schemaPaths) return null;
540
+ for (const schemaPathKey in schemaPaths) {
541
+ // Convert schemaPathKey to a regex: e.g., /products/{id} -> ^/products/[^/]+$
542
+ const regexPattern = '^' + schemaPathKey.replace(/{[^}]+}/g, '[^/]+') + '$';
543
+ const regex = new RegExp(regexPattern);
544
+ if (regex.test(requestUrl)) {
545
+ return schemaPathKey; // Return the original schema path key
546
+ }
547
+ }
548
+ return null;
549
+ };
550
+
551
+ const matchedSchemaPathKey = findMatchingOpenApiPath(url, openApiSchema ? openApiSchema.paths : null);
552
+
553
+ // Validate URL and method against OpenAPI schema
554
+ if (!openApiSchema || !matchedSchemaPathKey || !openApiSchema.paths[matchedSchemaPathKey][method.toLowerCase()]) {
555
+ const errorMessage = openApiSchema && openApiSchema.paths ?
556
+ `Invalid URL ('${url}') or method ('${method}') combination not found in API schema, or schema path not matched.` :
557
+ 'API schema not loaded, cannot validate URL and method.';
558
+ const error = new Error(errorMessage);
559
+ error.status = 400;
560
+ throw error;
561
+ }
562
+
563
+ // Create a user token for the job test
564
+ const jobToken = jwt.sign({ userId }, process.env.jwtSecret);
565
+
566
+ // Start transaction
567
+ transaction = await sqldb.sequelize.transaction();
568
+
569
+ // Create job in database
570
+ const cronJobData = {
571
+ userId,
572
+ // bullJobId will be set by the hook
573
+ cron,
574
+ method,
575
+ url,
576
+ token: jobToken,
577
+ body: { // Ensure body includes callbackUrl if present, or is an empty object if body is null/undefined
578
+ ...(body || {}),
579
+ ...(callbackUrl ? { callbackUrl } : {}),
580
+ },
581
+ removeOnComplete: removeOnComplete || false, // Ensure it defaults to false if not provided
582
+ };
583
+
584
+ const cronJob = await sqldb.ApiCronJobs.create(cronJobData, { transaction });
585
+
586
+ await transaction.commit();
587
+ // The cronJob instance returned here should have bullJobId populated by the hook
588
+ return res.json(R.omit(['token'], cronJob.get({ plain:true })));
589
+ } catch (err) {
590
+ if (transaction) await transaction.rollback();
591
+ // No need to manually cleanup bullJobId here.
592
+ // If hook failed, transaction is rolled back by the hook re-throwing,
593
+ // or by create itself failing.
594
+ // If create itself failed before hook, no bull job was created.
595
+ return next(err);
596
+ }
597
+ };
598
+
599
+ const listCronjobs = async (req, res, next) => {
600
+ const {
601
+ params: {
602
+ userId,
603
+ },
604
+ } = req;
605
+
606
+ try {
607
+ // Get all jobs from Bull queue
608
+ const bullJobs = await queue.getJobs(['active', 'wait', 'delayed']);
609
+ const bullJobIds = new Set(bullJobs.map(job => job.id));
610
+
611
+ // Get repeatable jobs
612
+ const repeatableJobs = await queue.getRepeatableJobs();
613
+ const repeatablePatterns = new Set(repeatableJobs.map(job => job.cron));
614
+
615
+ // Get jobs from database
616
+ const jobs = await sqldb.ApiCronJobs.findAll({
617
+ where: {
618
+ userId,
619
+ },
620
+ attributes: { exclude: ['token'] },
621
+ raw: true,
622
+ });
623
+
624
+ // Add inQueue status to jobs
625
+ const jobsWithStatus = jobs.map(job => {
626
+ const jobIdParts = job.bullJobId.split(':');
627
+ const isRepeatJob = jobIdParts[0] === 'repeat';
628
+ let isInQueue = false;
629
+
630
+ if (isRepeatJob) {
631
+ isInQueue = repeatablePatterns.has(job.cron);
632
+ } else {
633
+ isInQueue = bullJobIds.has(job.bullJobId);
634
+ }
635
+ return { ...job, inQueue: isInQueue };
636
+ });
637
+
638
+ return res.json({ jobs: jobsWithStatus });
639
+ } catch (err) {
640
+ return next(err);
641
+ }
642
+ };
643
+
644
+ const deleteCronjob = async (req, res, next) => {
645
+ const {
646
+ params: {
647
+ userId,
648
+ id, // Changed from jobId to id
649
+ },
650
+ } = req;
651
+
652
+ // Extract token from Authorization header
653
+ const getToken = (req) => {
654
+ if (
655
+ !req.header('Authorization') ||
656
+ req.header('Authorization').substring(0, 7) !== 'Bearer '
657
+ ) return null;
658
+ return req.header('Authorization').split(' ')[1];
659
+ };
660
+
661
+ const token = getToken(req);
662
+ let transaction; // Add transaction variable for deleteCronjob
663
+
664
+ try {
665
+ // Check if user is admin
666
+ const isAdmin = token === process.env.adminKey;
667
+
668
+ // For non-admin users, verify token
669
+ if (!isAdmin) {
670
+ try {
671
+ const decoded = jwt.verify(token, process.env.jwtSecret);
672
+ // Verify userId in token matches requested userId
673
+ if (!decoded || !decoded.userId || decoded.userId !== userId) {
674
+ throw new Error('User ID mismatch');
675
+ }
676
+ } catch (err) {
677
+ return res.status(403).json({
678
+ status: 403,
679
+ message: 'Forbidden'
680
+ });
681
+ }
682
+ }
683
+
684
+ // Find the cronjob in the database
685
+ const cronJob = await sqldb.ApiCronJobs.findOne({
686
+ where: {
687
+ userId,
688
+ id, // Changed from bullJobId to id
689
+ },
690
+ });
691
+
692
+ // If cronjob doesn't exist, return 404
693
+ if (!cronJob) {
694
+ const error = new Error('Cronjob not found');
695
+ error.status = 404;
696
+ return next(error);
697
+ }
698
+
699
+ // For non-admin users, verify ownership of the cronjob
700
+ if (!isAdmin && userId !== cronJob.userId) {
701
+ return res.status(403).json({
702
+ status: 403,
703
+ message: 'Forbidden'
704
+ });
705
+ }
706
+
707
+ // Start transaction
708
+ transaction = await sqldb.sequelize.transaction();
709
+
710
+ // Remove from database. The beforeDestroy hook in ApiCronJobs model
711
+ // will handle removing the job from the Bull queue.
712
+ // Pass the transaction to ensure atomicity.
713
+ // The destroy operation will only succeed if the beforeDestroy hook (including removeJob) succeeds.
714
+ const destroyedRows = await sqldb.ApiCronJobs.destroy({
715
+ where: {
716
+ // id, // cronJob instance is already fetched and verified
717
+ id: cronJob.id, // Use the id from the fetched cronJob instance
718
+ userId: cronJob.userId, // Ensure we are deleting the correct user's job
719
+ },
720
+ transaction, // Pass transaction to destroy
721
+ });
722
+
723
+ // It's good practice to check if any rows were actually deleted,
724
+ // though in this flow, cronJob existence is checked prior.
725
+ if (destroyedRows === 0) {
726
+ // This case should ideally not be reached if cronJob was found
727
+ if (transaction) await transaction.rollback();
728
+ const error = new Error('Cronjob not found for deletion, though it was fetched prior.');
729
+ error.status = 404; // Or 500 for internal inconsistency
730
+ return next(error);
731
+ }
732
+
733
+ await transaction.commit();
734
+ // Return success response
735
+ return res.json({ success: true });
736
+ } catch (err) {
737
+ if (transaction) await transaction.rollback(); // Rollback transaction on error
738
+ // Pass along errors with status
739
+ if (err.status) {
740
+ return next(err);
741
+ }
742
+
743
+ // Otherwise, create a generic 500 error
744
+ const error = new Error(err.message || 'Internal Server Error');
745
+ error.status = 500;
746
+ return next(error); // Changed from next(err) to next(the new error object) for consistency
747
+ }
748
+ };
749
+
490
750
  module.exports = plugins => ({
491
751
  createAppToken,
492
752
  getAppToken,
@@ -504,4 +764,7 @@ module.exports = plugins => ({
504
764
  tokenTemplate,
505
765
  validateAppToken: validateAppToken(plugins),
506
766
  getAffiliates: getAffiliates(plugins),
767
+ createCronjob,
768
+ deleteCronjob,
769
+ listCronjobs,
507
770
  });
package/index.js CHANGED
@@ -312,9 +312,8 @@ module.exports = async ({
312
312
  addJob({
313
313
  type: 'callback',
314
314
  payload: callbackBody,
315
- }).then(jobId => {
316
- console.debug('created callback job', jobId);
317
- });
315
+ removeOnComplete: true,
316
+ }).then();
318
317
  return originalJson.call(this, data);
319
318
  };
320
319
  }
@@ -0,0 +1,12 @@
1
+ module.exports = {
2
+ up: async (queryInterface, Sequelize) => {
3
+ await queryInterface.addColumn('CronJobs', 'token', {
4
+ type: Sequelize.STRING,
5
+ allowNull: true
6
+ });
7
+ },
8
+
9
+ down: async (queryInterface) => {
10
+ await queryInterface.removeColumn('CronJobs', 'token');
11
+ }
12
+ };
@@ -0,0 +1,61 @@
1
+ module.exports = {
2
+ up: async (queryInterface, Sequelize) => {
3
+ // Create a new table with the updated schema
4
+ await queryInterface.createTable('ApiCronJobs', {
5
+ id: {
6
+ type: Sequelize.UUID,
7
+ defaultValue: Sequelize.UUIDV4,
8
+ primaryKey: true,
9
+ },
10
+ userId: {
11
+ type: Sequelize.STRING,
12
+ allowNull: false,
13
+ },
14
+ bullJobId: {
15
+ type: Sequelize.STRING,
16
+ allowNull: true,
17
+ },
18
+ cron: {
19
+ type: Sequelize.STRING,
20
+ allowNull: false,
21
+ },
22
+ method: {
23
+ type: Sequelize.STRING,
24
+ allowNull: false,
25
+ },
26
+ url: {
27
+ type: Sequelize.STRING,
28
+ allowNull: false,
29
+ },
30
+ callbackUrl: {
31
+ type: Sequelize.STRING,
32
+ allowNull: true,
33
+ },
34
+ token: {
35
+ type: Sequelize.STRING,
36
+ allowNull: false,
37
+ },
38
+ body: {
39
+ type: Sequelize.JSON,
40
+ allowNull: true,
41
+ },
42
+ removeOnComplete: {
43
+ type: Sequelize.BOOLEAN,
44
+ allowNull: false,
45
+ defaultValue: false,
46
+ },
47
+ createdAt: {
48
+ type: Sequelize.DATE,
49
+ allowNull: false,
50
+ },
51
+ updatedAt: {
52
+ type: Sequelize.DATE,
53
+ allowNull: false,
54
+ },
55
+ });
56
+ },
57
+
58
+ down: async (queryInterface, Sequelize) => {
59
+ await queryInterface.dropTable('ApiCronJobs');
60
+ },
61
+ };
@@ -0,0 +1,96 @@
1
+ const Sequelize = require('sequelize');
2
+ const db = require('./db');
3
+
4
+ const ApiCronJobs = db.define('ApiCronJobs', {
5
+ id: {
6
+ type: Sequelize.UUID,
7
+ defaultValue: Sequelize.UUIDV4,
8
+ primaryKey: true,
9
+ },
10
+ userId: {
11
+ type: Sequelize.STRING,
12
+ allowNull: false,
13
+ },
14
+ bullJobId: {
15
+ type: Sequelize.STRING,
16
+ // It might be null initially until the afterCreate hook successfully adds the job
17
+ allowNull: true,
18
+ },
19
+ cron: {
20
+ type: Sequelize.STRING,
21
+ allowNull: false,
22
+ },
23
+ method: {
24
+ type: Sequelize.STRING,
25
+ allowNull: false,
26
+ },
27
+ url: {
28
+ type: Sequelize.STRING,
29
+ allowNull: false,
30
+ },
31
+ token: {
32
+ type: Sequelize.STRING,
33
+ allowNull: false,
34
+ },
35
+ body: {
36
+ type: Sequelize.JSON,
37
+ allowNull: true, // Assuming body can be optional
38
+ },
39
+ removeOnComplete: {
40
+ type: Sequelize.BOOLEAN,
41
+ allowNull: false,
42
+ defaultValue: false,
43
+ }
44
+ }, {
45
+ hooks: {
46
+ afterCreate: async (apiCronJob, options) => {
47
+ const { addJob } = require('../worker/queue');
48
+ const jobPayload = {
49
+ type: 'api',
50
+ method: apiCronJob.method,
51
+ url: apiCronJob.url,
52
+ headers: {
53
+ 'content-type': 'application/json',
54
+ 'Authorization': `Bearer ${apiCronJob.token}`,
55
+ },
56
+ payload: (apiCronJob.body || {}),
57
+ };
58
+
59
+ const jobParams = {
60
+ repeat: {
61
+ cron: apiCronJob.cron,
62
+ },
63
+ removeOnComplete: apiCronJob.removeOnComplete,
64
+ };
65
+
66
+ try {
67
+ // The addJob must be part of the same transaction that created apiCronJob
68
+ const bullJobId = await addJob(jobPayload, jobParams, options.transaction);
69
+ // Update the instance with the bullJobId and save it within the same transaction
70
+ // Note: This will trigger update hooks if any are defined, which is generally fine.
71
+ // apiCronJob.bullJobId = bullJobId;
72
+ await apiCronJob.update({ bullJobId }, { transaction: options.transaction, hooks: false });
73
+ } catch (err) {
74
+ // If addJob or the subsequent save fails, the transaction should be rolled back
75
+ // by the controller that initiated it.
76
+ // We re-throw the error to ensure the transaction is rolled back.
77
+ console.error('Failed to create Bull job or save bullJobId:', err);
78
+ throw err; // This will trigger the rollback in the controller
79
+ }
80
+ },
81
+ beforeDestroy: async (apiCronJob, options) => {
82
+ const { removeJob } = require('../worker/queue');
83
+ try {
84
+ // The removeJob must be part of the same transaction
85
+ await removeJob(apiCronJob.bullJobId, options.transaction);
86
+ } catch (err) {
87
+ // If removeJob fails, the transaction should be rolled back.
88
+ console.error('Failed to remove Bull job:', err);
89
+ throw err; // This will trigger the rollback in the controller
90
+ }
91
+ },
92
+ },
93
+ });
94
+
95
+ module.exports = ApiCronJobs;
96
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.94",
3
+ "version": "1.0.95",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -28,7 +28,7 @@
28
28
  "axios": "^0.27.2",
29
29
  "axios-curlirize": "^1.3.7",
30
30
  "bluebird": "^3.7.2",
31
- "bull": "^4.8.2",
31
+ "bull": "^4.16.5",
32
32
  "commander": "^9.2.0",
33
33
  "compression": "^1.7.4",
34
34
  "eventemitter2": "^6.4.9",
package/test/utils.js CHANGED
@@ -9,7 +9,10 @@ const Plugin = require('./plugin');
9
9
  const slugify = require('./slugify');
10
10
  const sqldb = require('../models/db');
11
11
 
12
- const { env: { adminKey } } = process;
12
+ const jwt = require('jsonwebtoken');
13
+ const { env: { adminKey, jwtSecret } } = process;
14
+
15
+ const createUserToken = (userId) => jwt.sign({ userId }, jwtSecret);
13
16
 
14
17
  afterAll(async () => {
15
18
  await sqldb.connectionManager.close();
@@ -51,8 +54,14 @@ module.exports = async (appParams = {}) => {
51
54
  } else {
52
55
  resp = await request(app)[verb](url).query(query).send(payload);
53
56
  }
54
- // if (resp.statusCode !== 200) assert.strictEqual(resp, `${url}-${expectStatusCode}`);
55
- assert.strictEqual(`${url}-${resp.statusCode}`, `${url}-${expectStatusCode}`);
57
+ if (resp.statusCode !== expectStatusCode) {
58
+ const error = new Error();
59
+ error.response = {
60
+ status: resp.statusCode,
61
+ data: resp.body
62
+ };
63
+ throw error;
64
+ }
56
65
  if (rawResponse) return resp;
57
66
  return resp.body;
58
67
  };
@@ -62,9 +71,9 @@ module.exports = async (appParams = {}) => {
62
71
  const doApiPut = params => doApi({ ...params, verb: 'put' });
63
72
  const doApiDelete = params => doApi({ ...params, verb: 'delete' });
64
73
 
65
- const appSetup = async ({ userId: userIdParam } = {}) => {
74
+ const appSetup = async ({ userId: userIdParam, appName: appNameParam } = {}) => {
66
75
  const userId = userIdParam || chance.guid();
67
- const appName1 = slugify(
76
+ const appName1 = appNameParam || slugify(
68
77
  chance.company(),
69
78
  ).toLowerCase();
70
79
  const newApp = {
@@ -77,31 +86,49 @@ module.exports = async (appParams = {}) => {
77
86
  endpoint: chance.url(),
78
87
  apiKey: apiKey1,
79
88
  };
80
- const { body: { value: appKey } } = await request(app)
81
- .post('/app')
82
- .set('Authorization', `Bearer ${adminKey}`)
83
- .send(newApp);
89
+
90
+ let appKey;
91
+
92
+ // Only create a new app if appNameParam is not provided
93
+ if (!appNameParam) {
94
+ const { body: { value: newAppKey } } = await request(app)
95
+ .post('/app')
96
+ .set('Authorization', `Bearer ${adminKey}`)
97
+ .send(newApp);
98
+ appKey = newAppKey;
99
+ } else {
100
+ // If appNameParam is provided, we'll use the adminKey directly
101
+ // This avoids the need for a GET /app/{appName} endpoint
102
+ appKey = adminKey;
103
+ }
104
+
105
+ // Create user token for the app
106
+ const hint = token.apiKey.split('-')[0];
84
107
  await request(app)
85
108
  .post(`/${newApp.name}/${userId}`)
86
109
  .set('Authorization', `Bearer ${appKey}`)
87
110
  .send({
88
- tokenHint: token.apiKey.split('-')[0],
111
+ tokenHint: hint,
89
112
  token,
90
113
  });
114
+
91
115
  return {
92
116
  newApp,
93
117
  token,
94
118
  appKey,
95
119
  userId,
120
+ hint,
96
121
  };
97
122
  };
98
123
 
99
124
  return {
125
+ app,
100
126
  appSetup,
101
127
  doApiDelete,
102
128
  doApiGet,
103
129
  doApiPost,
104
130
  doApiPut,
131
+ createUserToken,
105
132
  slugify,
106
133
  plugins: app.plugins,
107
134
  };
@@ -45,28 +45,6 @@ describe('worker: Callback job handling', () => {
45
45
  }));
46
46
  });
47
47
 
48
- it('should include the correct structure for callback jobs', () => {
49
- // This test verifies the worker/index.js handles callback jobs correctly
50
- // by checking the code structure without actually running it
51
-
52
- // Read the worker code
53
- const workerCode = require('fs').readFileSync(require('path').resolve(__dirname, '../index.js'), 'utf8');
54
-
55
- // Verify the worker code includes handling for callback jobs
56
- expect(workerCode).toContain('if (type === \'callback\')');
57
-
58
- // Verify it extracts the necessary parameters
59
- expect(workerCode).toContain('callbackUrl,');
60
- expect(workerCode).toContain('request,');
61
- expect(workerCode).toContain('result,');
62
-
63
- // Verify it calls the sendCallback function
64
- expect(workerCode).toContain('sendCallback');
65
-
66
- // Verify it returns the expected result structure
67
- expect(workerCode).toContain('return { success: true }');
68
- });
69
-
70
48
  it('should use the callback module to send callbacks', async () => {
71
49
  // Get a reference to the mocked axios.post function
72
50
  const axios = require('axios');