ti2 1.0.120 → 1.0.122

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/api.yml CHANGED
@@ -101,6 +101,17 @@ components:
101
101
  properties:
102
102
  value:
103
103
  type: string
104
+ CreateAppTokenResponse:
105
+ type: object
106
+ required:
107
+ - value
108
+ - isFirstIntegration
109
+ properties:
110
+ value:
111
+ type: string
112
+ isFirstIntegration:
113
+ type: boolean
114
+ description: True only when this transaction created the user's first integration credential.
104
115
  Status:
105
116
  type: object
106
117
  properties:
@@ -422,6 +433,15 @@ components:
422
433
  required: false
423
434
  description: Product Name wildcard match
424
435
  example: '*bus*'
436
+ omitServiceCodes:
437
+ required: false
438
+ description: Service codes to omit from the product catalog. When the integration has productSearchOmitServiceCodes, that list is used instead.
439
+ example: ['AC', 'SM']
440
+ oneOf:
441
+ - type: string
442
+ - type: array
443
+ items:
444
+ type: string
425
445
  credentials:
426
446
  required: false
427
447
  $ref: '#/components/schemas/integrationCredentials'
@@ -862,7 +882,7 @@ paths:
862
882
  content:
863
883
  application/json:
864
884
  schema:
865
- $ref: '#/components/schemas/Message'
885
+ $ref: '#/components/schemas/CreateAppTokenResponse'
866
886
  delete:
867
887
  tags:
868
888
  - app
@@ -7,7 +7,8 @@ The `$bookingsProductSearch` function in `controllers/bookings.js` implements a
7
7
  1. **Initialization**:
8
8
  * Retrieves user, application (`appKey`), and token details (including `hint`).
9
9
  * Determines the specific plugin function to call for product search (e.g., `searchProducts` or `searchProductsForItinerary`).
10
- * Calculates a `cacheKey` based on `userId`, `hint`, and a static `operationId` ('bookingsProductSearch').
10
+ * Injects the integration's configured `productSearchOmitServiceCodes` into the plugin payload so every caller for that token omits the same service codes. When the setting is empty, the request body is unchanged.
11
+ * Calculates a `cacheKey` based on `userId`, `hint`, and a static `operationId` (`bookingsProductSearch`). Changing the omit setting does not change the key; clear that integration's product-search cache and resync.
11
12
  * Defines two distinct lock keys derived from this `cacheKey`:
12
13
  * `pluginExecutionLockKey` (resolves to `${cacheKey}:lock`): Used to serialize direct calls to the plugin.
13
14
  * `jobQueueLockKey` (resolves to `${cacheKey}:jobLock`): Used to prevent multiple submissions of background refresh jobs.
@@ -271,6 +271,112 @@ describe('user: bookings controller - searchProducts', () => {
271
271
 
272
272
  });
273
273
  });
274
+ describe('configured productSearchOmitServiceCodes', () => {
275
+ const configuredOmitHint = 'configured-product-omit-service-codes';
276
+ let cacheKey;
277
+
278
+ beforeAll(async () => {
279
+ await globalUtils.appSetup({
280
+ appName: testAppName,
281
+ userId: testUserId,
282
+ tokenHint: configuredOmitHint,
283
+ token: {
284
+ endpoint: 'https://api.travelgatex.com/configured-omit',
285
+ apiKey: chance.guid(),
286
+ client: 'tourconnect-configured-omit',
287
+ productSearchOmitServiceCodes: ' sm, AC, ac ',
288
+ },
289
+ });
290
+ cacheKey = hash({
291
+ userId: testUserId,
292
+ hint: configuredOmitHint,
293
+ operationId: 'bookingsProductSearch',
294
+ });
295
+ });
296
+
297
+ beforeEach(async () => {
298
+ await Promise.all([
299
+ cache.drop({ pluginName: testAppName, key: cacheKey }),
300
+ cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` }),
301
+ cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` }),
302
+ cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }),
303
+ ]);
304
+ });
305
+
306
+ it('sends configured omissions when the caller omits the field', async () => {
307
+ await doApiPost({
308
+ url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
309
+ token: userToken,
310
+ payload: {},
311
+ });
312
+
313
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
314
+ expect(
315
+ travelgatePlugin.searchProducts.mock.calls[0][0].payload.omitServiceCodes,
316
+ ).toEqual(['AC', 'SM']);
317
+ expect(await cache.get({ pluginName: testAppName, key: cacheKey })).toBeTruthy();
318
+ });
319
+
320
+ it('keeps configured omissions during an empty-scope force refresh', async () => {
321
+ await doApiPost({
322
+ url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
323
+ token: userToken,
324
+ payload: { forceRefresh: true, omitServiceCodes: [] },
325
+ });
326
+
327
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
328
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload).toEqual({
329
+ forceRefresh: true,
330
+ omitServiceCodes: ['AC', 'SM'],
331
+ });
332
+ });
333
+
334
+ it('ignores extra request omit codes once the integration is configured', async () => {
335
+ await doApiPost({
336
+ url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
337
+ token: userToken,
338
+ payload: { omitServiceCodes: ['TR'] },
339
+ });
340
+
341
+ expect(
342
+ travelgatePlugin.searchProducts.mock.calls[0][0].payload.omitServiceCodes,
343
+ ).toEqual(['AC', 'SM']);
344
+ });
345
+
346
+ it('preserves configured omissions in stale background refreshes', async () => {
347
+ await cache.save({
348
+ pluginName: testAppName,
349
+ key: cacheKey,
350
+ value: { products: [{ productId: 'stale-product' }] },
351
+ ttl: 60,
352
+ });
353
+ await cache.save({
354
+ pluginName: testAppName,
355
+ key: `${cacheKey}:lastUpdated`,
356
+ value: 1,
357
+ ttl: 60,
358
+ });
359
+
360
+ const result = await doApiPost({
361
+ url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
362
+ token: userToken,
363
+ payload: {},
364
+ });
365
+
366
+ expect(result.products).toEqual([{ productId: 'stale-product' }]);
367
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
368
+ expect(addJob).toHaveBeenCalledTimes(1);
369
+ expect(addJob.mock.calls[0][0].payload.payload).toEqual({
370
+ forceRefresh: false,
371
+ omitServiceCodes: ['AC', 'SM'],
372
+ });
373
+ expect(addJob.mock.calls[0][0].postProcess.args).toEqual({
374
+ appKey: testAppName,
375
+ userId: testUserId,
376
+ hint: configuredOmitHint,
377
+ });
378
+ });
379
+ });
274
380
  describe('cache TTR and lock mechanism', () => {
275
381
  const ttrTestHint = 'ttr-test'; // This hint is for UserAppKey within 'travelgate' appName
276
382
  const shortTTRToken = {
@@ -20,7 +20,7 @@ describe('user', () => {
20
20
  let doApiGet; let doApiPost; let
21
21
  doApiDelete;
22
22
  let app;
23
- let sqldb;
23
+ let db;
24
24
  let appKey;
25
25
  const userId = chance.guid();
26
26
  let apiKey = chance.guid();
@@ -33,7 +33,7 @@ describe('user', () => {
33
33
  beforeAll(async () => {
34
34
  ({
35
35
  app,
36
- sqldb,
36
+ sqldb: db,
37
37
  doApiDelete,
38
38
  doApiGet,
39
39
  doApiPost,
@@ -41,6 +41,7 @@ describe('user', () => {
41
41
  } = await testUtils({
42
42
  plugins: [appName],
43
43
  }));
44
+ await db.models.UserAppKey.destroy({ where: { userId } });
44
45
  // create an App
45
46
  ({ value: appKey } = await doApiPost({
46
47
  url: '/app',
@@ -117,7 +118,7 @@ describe('user', () => {
117
118
  // set up new token
118
119
  apiKey = chance.guid();
119
120
  token.apiKey = apiKey;
120
- await doApiPost({
121
+ const createResult = await doApiPost({
121
122
  url: `/${appName}/${userId}`,
122
123
  token: userKey,
123
124
  payload: {
@@ -125,6 +126,7 @@ describe('user', () => {
125
126
  token,
126
127
  },
127
128
  });
129
+ expect(createResult.isFirstIntegration).toBe(true);
128
130
  // make sure the app token is there
129
131
  const { userAppKeys } = await doApiGet({
130
132
  url: `/user/${userId}/apps`,
@@ -140,11 +142,117 @@ describe('user', () => {
140
142
  ]),
141
143
  );
142
144
  });
145
+ it('reports post-save cron setup failure while preserving the committed credential', async () => {
146
+ const postCommitUserId = chance.guid();
147
+ const postCommitUserKey = (await doApiPost({
148
+ url: '/user',
149
+ token: adminKey,
150
+ payload: { userId: postCommitUserId },
151
+ })).value;
152
+ const cronLookup = jest.spyOn(db.models.CronJobs, 'findOne')
153
+ .mockRejectedValueOnce(new Error('cron unavailable'));
154
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
155
+ try {
156
+ const result = await doApiPost({
157
+ url: `/${appName}/${postCommitUserId}`,
158
+ token: postCommitUserKey,
159
+ payload: { tokenHint: 'post-commit', token },
160
+ expectStatusCode: 500,
161
+ });
162
+
163
+ expect(result.message).toContain('cron unavailable');
164
+ expect(await db.models.UserAppKey.count({ where: { userId: postCommitUserId } })).toBe(1);
165
+ } finally {
166
+ cronLookup.mockRestore();
167
+ consoleError.mockRestore();
168
+ }
169
+ });
170
+
171
+ it('serializes competing first integrations and marks exactly one as first', async () => {
172
+ const concurrentUserId = chance.guid();
173
+ const concurrentUserKey = (await doApiPost({
174
+ url: '/user',
175
+ token: adminKey,
176
+ payload: { userId: concurrentUserId },
177
+ })).value;
178
+ const lockTransaction = await db.transaction();
179
+ await db.models.User.findOne({
180
+ where: { userId: concurrentUserId },
181
+ transaction: lockTransaction,
182
+ lock: lockTransaction.LOCK.UPDATE,
183
+ });
184
+ const originalFindOne = db.models.User.findOne.bind(db.models.User);
185
+ const lockAttempts = [];
186
+ let releaseLockAttempts;
187
+ const bothLockAttempts = new Promise(resolveLockAttempts => {
188
+ releaseLockAttempts = resolveLockAttempts;
189
+ });
190
+ const findOne = jest.spyOn(db.models.User, 'findOne').mockImplementation(options => {
191
+ if (options.transaction !== lockTransaction && options.lock) {
192
+ lockAttempts.push(options);
193
+ if (lockAttempts.length === 2) releaseLockAttempts();
194
+ }
195
+ return originalFindOne(options);
196
+ });
197
+ let settled = 0;
198
+ let requests;
199
+ try {
200
+ requests = [
201
+ doApiPost({
202
+ url: `/${appName}/${concurrentUserId}`,
203
+ token: concurrentUserKey,
204
+ payload: { tokenHint: 'first-a', token },
205
+ }),
206
+ doApiPost({
207
+ url: `/${appName}/${concurrentUserId}`,
208
+ token: concurrentUserKey,
209
+ payload: { tokenHint: 'first-b', token },
210
+ }),
211
+ ].map(postRequest => postRequest.finally(() => { settled += 1; }));
212
+ await bothLockAttempts;
213
+ expect(lockAttempts).toHaveLength(2);
214
+ expect(settled).toBe(0);
215
+ } finally {
216
+ findOne.mockRestore();
217
+ await lockTransaction.commit();
218
+ }
219
+ const results = await Promise.all(requests);
220
+
221
+ expect(results.map(result => result.isFirstIntegration).sort())
222
+ .toEqual([false, true]);
223
+ expect(await db.models.UserAppKey.count({ where: { userId: concurrentUserId } })).toBe(2);
224
+ });
143
225
  const apiKey2 = chance.guid();
144
226
  const token2 = { ...token };
227
+ it('replaces a sole existing integration without marking the update as first', async () => {
228
+ const replaceUserId = chance.guid();
229
+ const replaceUserKey = (await doApiPost({
230
+ url: '/user',
231
+ token: adminKey,
232
+ payload: { userId: replaceUserId },
233
+ })).value;
234
+ const replaceHint = 'replace-existing';
235
+ const first = await doApiPost({
236
+ url: `/${appName}/${replaceUserId}`,
237
+ token: replaceUserKey,
238
+ payload: { tokenHint: replaceHint, token },
239
+ });
240
+ const replacement = await doApiPost({
241
+ url: `/${appName}/${replaceUserId}`,
242
+ token: replaceUserKey,
243
+ payload: { tokenHint: replaceHint, token: { ...token, apiKey: chance.guid() } },
244
+ });
245
+
246
+ expect(first.isFirstIntegration).toBe(true);
247
+ expect(replacement.isFirstIntegration).toBe(false);
248
+ expect(await db.models.UserAppKey.count({
249
+ where: { userId: replaceUserId, integrationId: appName, hint: replaceHint },
250
+ })).toBe(1);
251
+ });
252
+
145
253
  it('create a second token', async () => {
146
254
  // set up new token
147
- await doApiPost({
255
+ const createResult = await doApiPost({
148
256
  url: `/${appName}/${userId}`,
149
257
  token: userKey,
150
258
  payload: {
@@ -152,6 +260,7 @@ describe('user', () => {
152
260
  token: token2,
153
261
  },
154
262
  });
263
+ expect(createResult.isFirstIntegration).toBe(false);
155
264
  });
156
265
  it('should be able to read a user/app token', async () => {
157
266
  const returnValue = await doApiGet({
@@ -257,7 +366,7 @@ describe('user', () => {
257
366
  it('should return 422 when stored app token is corrupted', async () => {
258
367
  const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
259
368
  const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
260
- const [originalRows] = await sqldb.query(
369
+ const [originalRows] = await db.query(
261
370
  'SELECT id, appKey FROM UserAppKeys WHERE integrationId = :integrationId AND userId = :userId',
262
371
  {
263
372
  replacements: {
@@ -266,7 +375,7 @@ describe('user', () => {
266
375
  },
267
376
  },
268
377
  );
269
- await sqldb.query(
378
+ await db.query(
270
379
  'UPDATE UserAppKeys SET appKey = :appKey WHERE integrationId = :integrationId AND userId = :userId',
271
380
  {
272
381
  replacements: {
@@ -284,7 +393,7 @@ describe('user', () => {
284
393
  });
285
394
  expect(returnValue.message).toBe('Stored integration token is invalid. Please re-save credentials.');
286
395
  } finally {
287
- await Promise.all(originalRows.map(currentRow => sqldb.query(
396
+ await Promise.all(originalRows.map(currentRow => db.query(
288
397
  'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
289
398
  {
290
399
  replacements: {
@@ -300,7 +409,7 @@ describe('user', () => {
300
409
  it('should return 422 when stored app token is corrupted for hinted endpoint', async () => {
301
410
  const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
302
411
  const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
303
- const [originalRows] = await sqldb.query(
412
+ const [originalRows] = await db.query(
304
413
  'SELECT id, appKey FROM UserAppKeys WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
305
414
  {
306
415
  replacements: {
@@ -310,7 +419,7 @@ describe('user', () => {
310
419
  },
311
420
  },
312
421
  );
313
- await sqldb.query(
422
+ await db.query(
314
423
  'UPDATE UserAppKeys SET appKey = :appKey WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
315
424
  {
316
425
  replacements: {
@@ -329,7 +438,7 @@ describe('user', () => {
329
438
  });
330
439
  expect(returnValue.message).toBe('Stored integration token is invalid. Please re-save credentials.');
331
440
  } finally {
332
- await Promise.all(originalRows.map(currentRow => sqldb.query(
441
+ await Promise.all(originalRows.map(currentRow => db.query(
333
442
  'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
334
443
  {
335
444
  replacements: {
@@ -50,7 +50,6 @@ const tokenTemplate = async (req, res, next) => {
50
50
  try {
51
51
  const thePlugin = req.app.plugins.find(({ name }) => name === pluginName);
52
52
  assert(thePlugin);
53
- let template = thePlugin.tokenTemplate();
54
53
  const safeRegExp = el => {
55
54
  if (!el || !(el.regExp instanceof RegExp)) {
56
55
  return el;
@@ -64,24 +63,28 @@ const tokenTemplate = async (req, res, next) => {
64
63
  },
65
64
  };
66
65
  };
67
- template = R.map(safeRegExp, template);
68
- return res.json({
69
- template: {
70
- ...template,
71
- // ttlProducts and doNotCallPluginForProducts are being used at ti2 level
72
- // hence we should just by default allow them for all ti2 plugins
73
- ttlForProducts: {
74
- type: 'number',
75
- regExp: /.+/,
76
- default: 60 * 60 * 24, // 1 day
77
- },
78
- doNotCallPluginForProducts: {
79
- type: 'boolean',
80
- regExp: /.+/,
81
- default: false,
82
- },
66
+ const template = R.map(safeRegExp, {
67
+ ...thePlugin.tokenTemplate(),
68
+ // ttlProducts and doNotCallPluginForProducts are being used at ti2 level
69
+ // hence we should just by default allow them for all ti2 plugins
70
+ ttlForProducts: {
71
+ type: 'number',
72
+ regExp: /.+/,
73
+ default: 60 * 60 * 24, // 1 day
74
+ },
75
+ doNotCallPluginForProducts: {
76
+ type: 'boolean',
77
+ regExp: /.+/,
78
+ default: false,
79
+ },
80
+ productSearchOmitServiceCodes: {
81
+ type: 'text',
82
+ regExp: /^[\sA-Za-z0-9,]*$/,
83
+ default: '',
84
+ description: 'Service codes that every product search for this integration must omit',
83
85
  },
84
86
  });
87
+ return res.json({ template });
85
88
  } catch (err) {
86
89
  return next(err);
87
90
  }
@@ -98,6 +101,7 @@ const createAppToken = async (req, res, next) => {
98
101
  userId,
99
102
  },
100
103
  } = req;
104
+ let transaction;
101
105
  try {
102
106
  const payload = {
103
107
  integrationId,
@@ -105,20 +109,45 @@ const createAppToken = async (req, res, next) => {
105
109
  hint,
106
110
  appKey,
107
111
  };
108
- // check if the user exists
109
- const userRecord = await sqldb.User.findOne({ where: { userId } });
110
- if (!userRecord) { // create the user record
111
- await sqldb.User.create({ userId });
112
- }
113
- // check if the user already has the same app with the same hint
112
+ await sqldb.User.findOrCreate({ where: { userId }, defaults: { userId } });
113
+
114
+ transaction = await sqldb.sequelize.transaction();
115
+ await sqldb.User.findOne({
116
+ where: { userId },
117
+ transaction,
118
+ lock: transaction.LOCK.UPDATE,
119
+ });
120
+ const integrationCount = await sqldb.UserAppKey.count({
121
+ where: { userId },
122
+ transaction,
123
+ });
124
+ const isFirstIntegration = integrationCount === 0;
125
+ // Replacing an existing app/hint is an update, never a first integration.
114
126
  const userAppKeyDup = await sqldb.UserAppKey.findOne({
115
127
  where: { userId, integrationId, hint },
128
+ transaction,
116
129
  });
117
- if (userAppKeyDup) await userAppKeyDup.destroy();
118
- const newAppKey = await sqldb.UserAppKey.create(payload);
119
- // create any cronjobs related to the app
120
- await bb.each(req.app.plugins, async plugin => {
121
- if (Array.isArray(plugin.jobs)) {
130
+ if (userAppKeyDup) await userAppKeyDup.destroy({ transaction });
131
+ const newAppKey = await sqldb.UserAppKey.create(payload, { transaction });
132
+ try {
133
+ await transaction.commit();
134
+ } catch (commitErr) {
135
+ // Sequelize marks failed commits as finished, so a rollback would mask the original error.
136
+ transaction = null;
137
+ throw commitErr;
138
+ }
139
+ transaction = null;
140
+
141
+ const responsePayload = {
142
+ value: newAppKey.get('id').toString(),
143
+ isFirstIntegration,
144
+ };
145
+
146
+ // Credential persistence is already committed, but cron provisioning must
147
+ // remain caller-visible so missing scheduled functionality is not silent.
148
+ try {
149
+ await bb.each(req.app.plugins, async plugin => {
150
+ if (!Array.isArray(plugin.jobs)) return;
122
151
  const validJobs = plugin.jobs.filter(job => Boolean(job.cron) && Boolean(job.method));
123
152
  await bb.each(validJobs, async job => {
124
153
  const where = {
@@ -144,40 +173,48 @@ const createAppToken = async (req, res, next) => {
144
173
  ...(job.params || {}),
145
174
  removeOnComplete: false,
146
175
  };
147
- let bullJobId;
148
176
  if (existing) {
149
177
  const bullJob = await queue.getJob(job.bullJobId);
150
178
  if (!bullJob) {
151
- let rawBullJobId = await addJob(jobPayload, jobParams);
152
- existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object' && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
179
+ const rawBullJobId = await addJob(jobPayload, jobParams);
180
+ existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
181
+ && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
153
182
  await existing.save();
154
183
  } else {
155
- // make sure the cron is the same
156
- // bullJobId here is from existing.bullJobId, which should be a string.
157
- // The queue.getJob expects a string ID.
158
184
  const bullCron = R.path(
159
185
  ['opts', 'repeat', 'cron'],
160
- await queue.getJob(existing.bullJobId), // Use existing.bullJobId
186
+ await queue.getJob(existing.bullJobId),
161
187
  );
162
188
  if (bullCron !== job.cron) {
163
- await queue.removeJobs(existing.bullJobId); // Use existing.bullJobId
164
- let rawBullJobId = await addJob(jobPayload, jobParams);
165
- existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object' && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
189
+ await queue.removeJobs(existing.bullJobId);
190
+ const rawBullJobId = await addJob(jobPayload, jobParams);
191
+ existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
192
+ && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
166
193
  await existing.save();
167
194
  }
168
195
  }
169
196
  } else {
170
- let rawBullJobId = await addJob(jobPayload, jobParams);
171
- const actualBullJobId = (rawBullJobId && typeof rawBullJobId === 'object' && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
197
+ const rawBullJobId = await addJob(jobPayload, jobParams);
198
+ const actualBullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
199
+ && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
172
200
  await sqldb.CronJobs.create({ ...where, bullJobId: actualBullJobId });
173
201
  }
174
202
  });
175
- }
176
- });
177
-
203
+ });
204
+ } catch (cronErr) {
205
+ console.error('Unable to configure integration cron jobs after save', cronErr);
206
+ throw cronErr;
207
+ }
178
208
 
179
- return res.json({ value: newAppKey.get('id').toString() });
209
+ return res.json(responsePayload);
180
210
  } catch (err) {
211
+ if (transaction && !transaction.finished) {
212
+ try {
213
+ await transaction.rollback();
214
+ } catch (rollbackErr) {
215
+ console.error('Unable to roll back integration credential transaction', rollbackErr);
216
+ }
217
+ }
181
218
  return next(err);
182
219
  }
183
220
  };
@@ -50,7 +50,19 @@ const legacyProductSearchCacheEvents = {
50
50
  };
51
51
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
52
52
 
53
- const productSearchCacheKey = ({ userId, hint }) => hash({ userId, hint, operationId: productSearchOperationId });
53
+ const normalizeOmitServiceCodes = (...values) => [...new Set(
54
+ values
55
+ .reduce((items, value) => items.concat(Array.isArray(value) ? value : [value]), [])
56
+ .reduce((items, value) => items.concat(String(value == null ? '' : value).split(',')), [])
57
+ .map(value => value.trim().toUpperCase())
58
+ .filter(Boolean),
59
+ )].sort();
60
+
61
+ const productSearchCacheKey = ({ userId, hint }) => hash({
62
+ userId,
63
+ hint,
64
+ operationId: productSearchOperationId,
65
+ });
54
66
 
55
67
  const productSearchSelectorFields = [
56
68
  'searchInput',
@@ -58,7 +70,6 @@ const productSearchSelectorFields = [
58
70
  'productId',
59
71
  'productName',
60
72
  'lastUpdatedFrom',
61
- 'skipAccommodation',
62
73
  ];
63
74
 
64
75
  const hasProductSearchSelector = payload => productSearchSelectorFields.some(field => {
@@ -79,7 +90,17 @@ const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.pro
79
90
 
80
91
  const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
81
92
 
82
- const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId, searchInput, optionId, forceRefresh, startedAt }, action, extra = {}) => {
93
+ const emitProductSearchCacheDecision = ({
94
+ app,
95
+ cacheKey,
96
+ userId,
97
+ hint,
98
+ requestId,
99
+ searchInput,
100
+ optionId,
101
+ forceRefresh,
102
+ startedAt,
103
+ }, action, extra = {}) => {
83
104
  if (!app.events || !app.events.emit) return;
84
105
  const payload = {
85
106
  action,
@@ -303,27 +324,40 @@ const $bookingsProductSearch = plugins => async ({
303
324
  requestId,
304
325
  headers, // To pass original request headers for background job
305
326
  }) => {
327
+ const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
328
+ assert(userId, 'userId is required');
329
+ assert(appKey, 'appKey is required');
330
+ assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
331
+ const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
332
+
333
+ const configuredOmitServiceCodes = normalizeOmitServiceCodes(
334
+ R.path(['productSearchOmitServiceCodes'], token),
335
+ );
336
+ const normalizedRequestBody = {
337
+ ...originalRequestBody,
338
+ ...(configuredOmitServiceCodes.length ? {
339
+ omitServiceCodes: configuredOmitServiceCodes,
340
+ } : {}),
341
+ };
342
+
306
343
  // Extract controller-specific flags from originalRequestBody
307
344
  const {
308
345
  searchInput = '', // Provide defaults
309
346
  optionId = '',
310
347
  forceRefresh = false,
311
- } = originalRequestBody;
348
+ } = normalizedRequestBody;
312
349
 
313
- // Payload for the plugin function (func) - pass forceRefresh so plugins can trigger background cache rebuilds
314
- const payloadForPlugin = { ...R.omit(['forceRefresh'], originalRequestBody), forceRefresh };
315
- const isScopedSearch = hasProductSearchSelector(originalRequestBody);
350
+ // Keep forceRefresh in the plugin payload so plugins can trigger their own rebuilds.
351
+ const payloadForPlugin = {
352
+ ...R.omit(['forceRefresh'], normalizedRequestBody),
353
+ forceRefresh,
354
+ };
355
+ const isScopedSearch = hasProductSearchSelector(normalizedRequestBody);
316
356
  const payloadForBackgroundJob = R.omit(
317
357
  ['credentials', ...productSearchSelectorFields],
318
358
  payloadForPlugin,
319
359
  );
320
360
 
321
- const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
322
- assert(userId, 'userId is required');
323
- assert(appKey, 'appKey is required');
324
- assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
325
- const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
326
-
327
361
  const cacheKey = productSearchCacheKey({ userId, hint });
328
362
  const telemetryContext = {
329
363
  app,
@@ -336,7 +370,11 @@ const $bookingsProductSearch = plugins => async ({
336
370
  forceRefresh,
337
371
  startedAt: Date.now(),
338
372
  };
339
- const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(telemetryContext, action, extra);
373
+ const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(
374
+ telemetryContext,
375
+ action,
376
+ extra,
377
+ );
340
378
  const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
341
379
  const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
342
380
 
@@ -349,6 +387,7 @@ const $bookingsProductSearch = plugins => async ({
349
387
  const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
350
388
  emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
351
389
  cacheProductCount: productCount(initialActualCacheContent),
390
+ cacheOptionCount: optionCount(initialActualCacheContent),
352
391
  cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
353
392
  ttrMs: ttr * 1000,
354
393
  reason: hasPluginExecutionLock ? 'lockActive' : undefined,
@@ -416,6 +455,7 @@ const $bookingsProductSearch = plugins => async ({
416
455
  reason: 'waitedForLeader',
417
456
  lockWaitMs,
418
457
  cacheProductCount: productCount(cacheContent),
458
+ cacheOptionCount: optionCount(cacheContent),
419
459
  });
420
460
  return cacheContent;
421
461
  }
@@ -457,6 +497,7 @@ const $bookingsProductSearch = plugins => async ({
457
497
  reason,
458
498
  pluginElapsedMs,
459
499
  cacheProductCount: productCount(pluginResults),
500
+ cacheOptionCount: optionCount(pluginResults),
460
501
  });
461
502
  } else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
462
503
  emitDecision('partial_refresh_skipped', {
@@ -492,7 +533,9 @@ const $bookingsProductSearch = plugins => async ({
492
533
  emitDecision('cache_hit', {
493
534
  reason: 'doNotCallPluginForProducts',
494
535
  cacheProductCount: initialActualCacheContent.products.length,
536
+ cacheOptionCount: optionCount(initialActualCacheContent),
495
537
  returnedProductCount: searchResults.length,
538
+ returnedOptionCount: optionCount({ products: searchResults }),
496
539
  });
497
540
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
498
541
  }
@@ -522,7 +565,9 @@ const $bookingsProductSearch = plugins => async ({
522
565
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
523
566
  emitDecision(action, {
524
567
  cacheProductCount: initialActualCacheContent.products.length,
568
+ cacheOptionCount: optionCount(initialActualCacheContent),
525
569
  returnedProductCount: searchResults.length,
570
+ returnedOptionCount: optionCount({ products: searchResults }),
526
571
  cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
527
572
  ttrMs: ttr * 1000,
528
573
  ...extra,
@@ -879,7 +924,10 @@ const $updateProductSearchCache = plugins => async ({
879
924
  if (hasCacheableProductResults(pluginResult)) {
880
925
  await markRefreshAttempted();
881
926
  await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
882
- emitCacheEvent('cache_saved', { cacheProductCount: productCount(pluginResult) });
927
+ emitCacheEvent('cache_saved', {
928
+ cacheProductCount: productCount(pluginResult),
929
+ cacheOptionCount: optionCount(pluginResult),
930
+ });
883
931
  } else {
884
932
  const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
885
933
  const existingCacheContent = await app.cache.get({ key: cacheKey });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.120",
3
+ "version": "1.0.122",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/.windsurfrules DELETED
@@ -1,9 +0,0 @@
1
- This is a Node.js project using Sequelize for database operations and Express.js for the web server.
2
- The API is defined in api.yml using OpenAPI 3.0.2
3
- The database is defined in models/index.js
4
- The models are defined in models/
5
- to run tests you have to run them on the ti2 named docker container, and consider this codebase is part of node_modules/ti2 path for example :
6
- `` $ docker exec ti2 bash -c "cd /ti2 && npx jest lib/__tests__/callback.js --forceExit"``
7
- When writing / editing tests, make use of test/utils.js and review their implementation to understand how to mock the environment.
8
- Do not make any changes until you 95% confident of the changes you want to make, ask follow up questions until you have that confidence.
9
- when sending jobs to the background worker using the queue, asssume a worker is always runing to execute it but is on a different instance thread, so to review / wait for jobs we have to use the wokrer/queue.js listJobs fn to check for it
package/CLAUDE.md DELETED
@@ -1,166 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- Ti2 (Tourism Information Interchange) is an open-source integration framework for the tourism industry. It provides standardized functions for bookings, content, and rates management through a plugin-based architecture.
8
-
9
- ## Critical Development Rules
10
-
11
- 1. **Docker Container Execution**: ALL commands must be run inside the `ti2` Docker container
12
- - The codebase is located at `/ti2` path inside the container
13
- - This project is part of `node_modules/ti2` in the container environment
14
- - **NEVER run npm, node, or npx commands on the host machine**
15
- - Always use: `docker exec ti2 bash -c "cd /ti2 && <command>"`
16
- - Example for npm install: `docker exec ti2 bash -c "cd /ti2 && npm install <package>"`
17
-
18
- 2. **Testing Rules**:
19
- - Tests MUST be run inside the Docker container
20
- - Example: `docker exec ti2 bash -c "cd /ti2 && npx jest lib/__tests__/callback.js --forceExit"`
21
- - Use `test/utils.js` for mocking the environment
22
- - Review test utility implementations before writing/editing tests
23
-
24
- 3. **Background Jobs**:
25
- - Jobs sent to the queue run on a separate worker thread/instance
26
- - Use `worker/queue.js` `listJobs` function to check job status
27
- - Workers are always assumed to be running
28
-
29
- 4. **Code Confidence**: Do not make changes until 95% confident. Ask follow-up questions if uncertain.
30
-
31
- ## Public Repo Privacy
32
-
33
- - `ti2` is open source. Before opening or updating public PRs, review summaries, inline comments, release notes, or commit messages, scrub internal TourConnect service names and private implementation details.
34
- - Do not mention internal services such as `pyfilematch`, `filematch`, `tcoutlook`, private frontend names, private trace URLs, private customer identifiers, or internal deployment architecture in public GitHub text.
35
- - Use generic wording such as `upstream caller`, `internal catalog caller`, `private frontend`, `caller-side timeout`, or `internal workflow` when context is needed.
36
- - After editing public GitHub text, search the PR body, issue comments, review comments, and review summaries for internal service names before handoff.
37
-
38
- ## Common Development Commands
39
-
40
- ```bash
41
- # Run tests (inside Docker container)
42
- docker exec ti2 bash -c "cd /ti2 && npm test"
43
- docker exec ti2 bash -c "cd /ti2 && npx jest [test-file] --forceExit"
44
-
45
- # Run specific test file
46
- docker exec ti2 bash -c "cd /ti2 && npx jest controllers/__tests__/bookings.js --forceExit"
47
-
48
- # Database migrations
49
- docker exec ti2 bash -c "cd /ti2 && npm run sequelize db:migrate"
50
-
51
- # Generate documentation
52
- docker exec ti2 bash -c "cd /ti2 && npm run doc"
53
- ```
54
-
55
- ## Architecture
56
-
57
- ### Core Components
58
-
59
- 1. **Main Entry Point** (`index.js`):
60
- - Initializes Express server on port 10010 (default)
61
- - Sets up Swagger documentation at `/api-docs`
62
- - Handles plugin instantiation and middleware
63
- - Implements caching mechanism
64
- - Manages background job processing via `worker/queue.js`
65
-
66
- 2. **API Definition** (`api.yml`):
67
- - OpenAPI 3.0.0 specification
68
- - Defines all endpoints and schemas
69
- - JWT Bearer authentication
70
-
71
- 3. **Plugin System**:
72
- - Two types: Integration Plugins (connect systems) and App Plugins (value-added tools)
73
- - Plugins are instantiated with cache, axios, events, and configuration
74
- - Plugin schemas are merged with main API schema
75
- - Each plugin gets its own routes under `/apps/{pluginName}`
76
-
77
- 4. **Controllers** (`controllers/`):
78
- - `app.js`: Core application controller
79
- - `bookings.js`: Booking operations (searchProducts, createBooking, etc.)
80
- - `admin.js`: Administrative functions
81
- - `user.js`: User management
82
- - `ping.js`: Health check endpoints
83
- - `allotment.js`: Allotment management
84
-
85
- 5. **Database Models** (`models/`):
86
- - Sequelize ORM with MySQL
87
- - Key models: Integration, User, UserAppKey, CronJobs, ApiCronJobs
88
- - Migrations in `migrations/` directory
89
-
90
- 6. **Worker System** (`worker/`):
91
- - Bull queue for background job processing
92
- - Redis-backed job queue
93
- - Supports plugin jobs, API jobs, and callback jobs
94
- - Multi-worker support with throng
95
-
96
- 7. **Authentication** (`auth/authHandler.js`):
97
- - JWT-based authentication
98
- - Bearer token scheme
99
-
100
- ### Key Features
101
-
102
- - **Caching**: Redis-based caching with configurable TTL
103
- - **Event System**: EventEmitter2 for plugin communication
104
- - **Background Jobs**: Async job processing with Bull/Redis
105
- - **Cron Jobs**: Scheduled task execution
106
- - **Multi-tenancy**: Support for multiple integrations/plugins
107
-
108
- ### Plugin Development
109
-
110
- Plugins must implement standard methods:
111
- - `validateToken`: Token validation
112
- - `tokenTemplate`: Token configuration template
113
- - Booking methods: `searchProducts`, `searchAvailability`, `createBooking`, `cancelBooking`
114
- - Content methods: `getProducts`, `getProduct`, `createProduct`, `updateProduct`
115
-
116
- ### Testing Strategy
117
-
118
- - Jest testing framework
119
- - Test fixtures in `__fixtures__/`
120
- - Mock plugins in `__mocks__/`
121
- - Use `test/utils.js` for creating test environments
122
- - Tests organized by controller in `controllers/__tests__/`
123
-
124
- ## Database
125
-
126
- - Sequelize ORM with MySQL/MariaDB
127
- - Connection managed through `models/db.js`
128
- - Migrations handled via sequelize-cli
129
- - Models auto-loaded from `models/` directory
130
-
131
- ## Environment Variables
132
-
133
- Key variables:
134
- - `PORT`: Server port (default: 10010)
135
- - `REDIS_URL`: Redis connection (default: redis://redis:6379)
136
- - `jwtSecret`: JWT signing secret
137
- - `adminKey`: Admin authentication key
138
- - `WEB_CONCURRENCY`: Number of workers
139
- - `MAX_JOBS_PER_WORKER`: Job concurrency per worker
140
- - `SSL_INSECURE_ALLOWED_DOMAINS`: Pipe-separated list of domains where SSL certificate verification is skipped (e.g., `localhost|staging.example.com|192.168.1.100`)
141
-
142
- Plugin-specific variables follow pattern: `ti2_{pluginName}_{setting}`
143
-
144
- ### SSL Configuration
145
-
146
- The `SSL_INSECURE_ALLOWED_DOMAINS` environment variable allows you to specify domains where SSL certificate verification should be skipped. This is useful for:
147
- - Development environments with self-signed certificates
148
- - Testing against staging servers with invalid certificates
149
- - Local development with HTTPS
150
-
151
- Example usage:
152
- ```bash
153
- # Skip SSL verification for specific domains
154
- export SSL_INSECURE_ALLOWED_DOMAINS="localhost|staging.api.com|192.168.1.100"
155
-
156
- # Multiple domains separated by pipe
157
- export SSL_INSECURE_ALLOWED_DOMAINS="dev.example.com|test.example.com"
158
- ```
159
-
160
- **Important Implementation Notes**:
161
- - SSL configuration is ONLY applied to plugin axios instances (for external API calls) and worker axios instances
162
- - Internal communication (callbacks, per-request axios) do NOT use SSL configuration
163
- - SSL configuration uses a per-request interceptor approach for better isolation
164
- - The configuration checks domains dynamically on each request
165
-
166
- **Warning**: Only use this in development/testing environments. Never skip SSL verification in production for security reasons.