ti2 1.0.94 → 1.0.96

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 => {
@@ -127,30 +142,34 @@ const createAppToken = async (req, res, next) => {
127
142
  if (existing) {
128
143
  const bullJob = await queue.getJob(job.bullJobId);
129
144
  if (!bullJob) {
130
- bullJobId = await addJob(jobPayload, jobParams);
131
- existing.bullJobId = bullJobId;
145
+ let rawBullJobId = await addJob(jobPayload, jobParams);
146
+ existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object' && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
132
147
  await existing.save();
133
148
  } else {
134
149
  // make sure the cron is the same
150
+ // bullJobId here is from existing.bullJobId, which should be a string.
151
+ // The queue.getJob expects a string ID.
135
152
  const bullCron = R.path(
136
153
  ['opts', 'repeat', 'cron'],
137
- await queue.getJob(bullJobId),
154
+ await queue.getJob(existing.bullJobId), // Use existing.bullJobId
138
155
  );
139
156
  if (bullCron !== job.cron) {
140
- await queue.removeJobs(bullJobId);
141
- bullJobId = await addJob(jobPayload, jobParams);
142
- existing.bullJobId = bullJobId;
157
+ await queue.removeJobs(existing.bullJobId); // Use existing.bullJobId
158
+ let rawBullJobId = await addJob(jobPayload, jobParams);
159
+ existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object' && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
143
160
  await existing.save();
144
161
  }
145
162
  }
146
163
  } else {
147
- bullJobId = await addJob(jobPayload, jobParams);
148
- await sqldb.CronJobs.create({ ...where, bullJobId });
164
+ let rawBullJobId = await addJob(jobPayload, jobParams);
165
+ const actualBullJobId = (rawBullJobId && typeof rawBullJobId === 'object' && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
166
+ await sqldb.CronJobs.create({ ...where, bullJobId: actualBullJobId });
149
167
  }
150
168
  });
151
169
  }
152
170
  });
153
171
 
172
+
154
173
  return res.json({ value: newAppKey.get('id').toString() });
155
174
  } catch (err) {
156
175
  return next(err);
@@ -297,7 +316,7 @@ const migrateApp = async ({ integrationId, action }) => {
297
316
  'migrations',
298
317
  );
299
318
  try {
300
- await fs.access(migrationsPath);
319
+ await fs.promises.access(migrationsPath);
301
320
  } catch (err) {
302
321
  throw Error(`Could not find any migrations for ${integrationId} on ${migrationsPath}`);
303
322
  }
@@ -332,12 +351,10 @@ const migrateApp = async ({ integrationId, action }) => {
332
351
  const getAppScheduledJobs = async ({
333
352
  integrationId,
334
353
  userId,
335
- hint,
336
354
  }) => {
337
355
  const where = R.reject(R.isNil)({
338
356
  pluginName: integrationId,
339
357
  userId,
340
- hint,
341
358
  });
342
359
  const jobs = await sqldb.CronJobs.findAll({
343
360
  where,
@@ -487,6 +504,252 @@ const getAppToken = async (req, res, next) => {
487
504
  return res.json({ token: await userAppKey.token });
488
505
  };
489
506
 
507
+ const createCronjob = async (req, res, next) => {
508
+ const {
509
+ params: {
510
+ userId,
511
+ },
512
+ body: {
513
+ method,
514
+ url,
515
+ cron,
516
+ callbackUrl,
517
+ body,
518
+ removeOnComplete,
519
+ },
520
+ } = req;
521
+
522
+ let transaction;
523
+
524
+ try {
525
+ // Validate required fields
526
+ if (!method || !url || !cron) {
527
+ const error = new Error('method, url, and cron are required fields');
528
+ error.status = 400;
529
+ throw error;
530
+ }
531
+
532
+ // Validate URL path
533
+ const validUrlPattern = /^\/[\w\-\/\.]+$/;
534
+ if (!validUrlPattern.test(url)) {
535
+ const error = new Error('Invalid URL path');
536
+ error.status = 400;
537
+ throw error;
538
+ }
539
+
540
+ // Helper function to find a matching path in the OpenAPI schema
541
+ const findMatchingOpenApiPath = (requestUrl, schemaPaths) => {
542
+ if (!schemaPaths) return null;
543
+ for (const schemaPathKey in schemaPaths) {
544
+ // Convert schemaPathKey to a regex: e.g., /products/{id} -> ^/products/[^/]+$
545
+ const regexPattern = '^' + schemaPathKey.replace(/{[^}]+}/g, '[^/]+') + '$';
546
+ const regex = new RegExp(regexPattern);
547
+ if (regex.test(requestUrl)) {
548
+ return schemaPathKey; // Return the original schema path key
549
+ }
550
+ }
551
+ return null;
552
+ };
553
+
554
+ const matchedSchemaPathKey = findMatchingOpenApiPath(url, openApiSchema ? openApiSchema.paths : null);
555
+
556
+ // Validate URL and method against OpenAPI schema
557
+ if (!openApiSchema || !matchedSchemaPathKey || !openApiSchema.paths[matchedSchemaPathKey][method.toLowerCase()]) {
558
+ const errorMessage = openApiSchema && openApiSchema.paths ?
559
+ `Invalid URL ('${url}') or method ('${method}') combination not found in API schema, or schema path not matched.` :
560
+ 'API schema not loaded, cannot validate URL and method.';
561
+ const error = new Error(errorMessage);
562
+ error.status = 400;
563
+ throw error;
564
+ }
565
+
566
+ // Create a user token for the job test
567
+ const jobToken = jwt.sign({ userId }, process.env.jwtSecret);
568
+
569
+ // Start transaction
570
+ transaction = await sqldb.sequelize.transaction();
571
+
572
+ // Create job in database
573
+ const cronJobData = {
574
+ userId,
575
+ // bullJobId will be set by the hook
576
+ cron,
577
+ method,
578
+ url,
579
+ token: jobToken,
580
+ body: { // Ensure body includes callbackUrl if present, or is an empty object if body is null/undefined
581
+ ...(body || {}),
582
+ ...(callbackUrl ? { callbackUrl } : {}),
583
+ },
584
+ removeOnComplete: removeOnComplete || false, // Ensure it defaults to false if not provided
585
+ };
586
+
587
+ const cronJob = await sqldb.ApiCronJobs.create(cronJobData, { transaction });
588
+
589
+ await transaction.commit();
590
+ // The cronJob instance returned here should have bullJobId populated by the hook
591
+ return res.json(R.omit(['token'], cronJob.get({ plain:true })));
592
+ } catch (err) {
593
+ if (transaction) await transaction.rollback();
594
+ // No need to manually cleanup bullJobId here.
595
+ // If hook failed, transaction is rolled back by the hook re-throwing,
596
+ // or by create itself failing.
597
+ // If create itself failed before hook, no bull job was created.
598
+ return next(err);
599
+ }
600
+ };
601
+
602
+ const listCronjobs = async (req, res, next) => {
603
+ const {
604
+ params: {
605
+ userId,
606
+ },
607
+ } = req;
608
+
609
+ try {
610
+ // Get all jobs from Bull queue
611
+ const bullJobs = await queue.getJobs(['active', 'wait', 'delayed']);
612
+ const bullJobIds = new Set(bullJobs.map(job => job.id));
613
+
614
+ // Get repeatable jobs
615
+ const repeatableJobs = await queue.getRepeatableJobs();
616
+ const repeatablePatterns = new Set(repeatableJobs.map(job => job.cron));
617
+
618
+ // Get jobs from database
619
+ const jobs = await sqldb.ApiCronJobs.findAll({
620
+ where: {
621
+ userId,
622
+ },
623
+ attributes: { exclude: ['token'] },
624
+ raw: true,
625
+ });
626
+
627
+ // Add inQueue status to jobs
628
+ const jobsWithStatus = jobs.map(job => {
629
+ const jobIdParts = job.bullJobId.split(':');
630
+ const isRepeatJob = jobIdParts[0] === 'repeat';
631
+ let isInQueue = false;
632
+
633
+ if (isRepeatJob) {
634
+ isInQueue = repeatablePatterns.has(job.cron);
635
+ } else {
636
+ isInQueue = bullJobIds.has(job.bullJobId);
637
+ }
638
+ return { ...job, inQueue: isInQueue };
639
+ });
640
+
641
+ return res.json({ jobs: jobsWithStatus });
642
+ } catch (err) {
643
+ return next(err);
644
+ }
645
+ };
646
+
647
+ const deleteCronjob = async (req, res, next) => {
648
+ const {
649
+ params: {
650
+ userId,
651
+ id, // Changed from jobId to id
652
+ },
653
+ } = req;
654
+
655
+ // Extract token from Authorization header
656
+ const getToken = (req) => {
657
+ if (
658
+ !req.header('Authorization') ||
659
+ req.header('Authorization').substring(0, 7) !== 'Bearer '
660
+ ) return null;
661
+ return req.header('Authorization').split(' ')[1];
662
+ };
663
+
664
+ const token = getToken(req);
665
+ let transaction; // Add transaction variable for deleteCronjob
666
+
667
+ try {
668
+ // Check if user is admin
669
+ const isAdmin = token === process.env.adminKey;
670
+
671
+ // For non-admin users, verify token
672
+ if (!isAdmin) {
673
+ try {
674
+ const decoded = jwt.verify(token, process.env.jwtSecret);
675
+ // Verify userId in token matches requested userId
676
+ if (!decoded || !decoded.userId || decoded.userId !== userId) {
677
+ throw new Error('User ID mismatch');
678
+ }
679
+ } catch (err) {
680
+ return res.status(403).json({
681
+ status: 403,
682
+ message: 'Forbidden'
683
+ });
684
+ }
685
+ }
686
+
687
+ // Find the cronjob in the database
688
+ const cronJob = await sqldb.ApiCronJobs.findOne({
689
+ where: {
690
+ userId,
691
+ id, // Changed from bullJobId to id
692
+ },
693
+ });
694
+
695
+ // If cronjob doesn't exist, return 404
696
+ if (!cronJob) {
697
+ const error = new Error('Cronjob not found');
698
+ error.status = 404;
699
+ return next(error);
700
+ }
701
+
702
+ // For non-admin users, verify ownership of the cronjob
703
+ if (!isAdmin && userId !== cronJob.userId) {
704
+ return res.status(403).json({
705
+ status: 403,
706
+ message: 'Forbidden'
707
+ });
708
+ }
709
+
710
+ // Start transaction
711
+ transaction = await sqldb.sequelize.transaction();
712
+
713
+ // Remove from database. The beforeDestroy hook in ApiCronJobs model
714
+ // will handle removing the job from the Bull queue.
715
+ // Pass the transaction to ensure atomicity.
716
+ // The destroy operation will only succeed if the beforeDestroy hook (including removeJob) succeeds.
717
+ const destroyedRows = await sqldb.ApiCronJobs.destroy({
718
+ where: {
719
+ // id, // cronJob instance is already fetched and verified
720
+ id: cronJob.id, // Use the id from the fetched cronJob instance
721
+ userId: cronJob.userId, // Ensure we are deleting the correct user's job
722
+ },
723
+ transaction, // Pass transaction to destroy
724
+ });
725
+
726
+ // It's good practice to check if any rows were actually deleted,
727
+ // though in this flow, cronJob existence is checked prior.
728
+ if (destroyedRows === 0) {
729
+ // This case should ideally not be reached if cronJob was found
730
+ if (transaction) await transaction.rollback();
731
+ const error = new Error('Cronjob not found for deletion, though it was fetched prior.');
732
+ error.status = 404; // Or 500 for internal inconsistency
733
+ return next(error);
734
+ }
735
+
736
+ await transaction.commit();
737
+ // Return success response
738
+ return res.json({ success: true });
739
+ } catch (err) {
740
+ if (transaction) await transaction.rollback(); // Rollback transaction on error
741
+ // Pass along errors with status
742
+ if (err.status) {
743
+ return next(err);
744
+ }
745
+
746
+ // Otherwise, create a generic 500 error
747
+ const error = new Error(err.message || 'Internal Server Error');
748
+ error.status = 500;
749
+ return next(error); // Changed from next(err) to next(the new error object) for consistency
750
+ }
751
+ };
752
+
490
753
  module.exports = plugins => ({
491
754
  createAppToken,
492
755
  getAppToken,
@@ -504,4 +767,7 @@ module.exports = plugins => ({
504
767
  tokenTemplate,
505
768
  validateAppToken: validateAppToken(plugins),
506
769
  getAffiliates: getAffiliates(plugins),
770
+ createCronjob,
771
+ deleteCronjob,
772
+ listCronjobs,
507
773
  });