ti2 1.0.121 → 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
@@ -433,6 +433,15 @@ components:
433
433
  required: false
434
434
  description: Product Name wildcard match
435
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
436
445
  credentials:
437
446
  required: false
438
447
  $ref: '#/components/schemas/integrationCredentials'
@@ -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 = {
@@ -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
  }
@@ -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.121",
3
+ "version": "1.0.122",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {