ti2 1.0.115 → 1.0.118

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,4 +1,5 @@
1
1
  const assert = require('assert');
2
+ const crypto = require('crypto');
2
3
  const hash = require('object-hash');
3
4
  const R = require('ramda');
4
5
  const { UserAppKey } = require('../models/index');
@@ -28,6 +29,94 @@ const typeDefsAndQueries = {
28
29
  itineraryBookingQuery,
29
30
  };
30
31
 
32
+ const getPositiveIntegerEnv = (name, defaultValue) => {
33
+ const value = Number(process.env[name]);
34
+ const integerValue = Math.floor(value);
35
+ return Number.isFinite(value) && integerValue > 0 ? integerValue : defaultValue;
36
+ };
37
+
38
+ const getProductSearchLockTtlSeconds = () => getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
39
+ const productSearchLockWaitMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
40
+ const productSearchLockPollMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_POLL_MS', 250);
41
+ const emptyProductSearchCacheTtlSeconds = 60;
42
+ const productSearchCacheTtlSeconds = 30 * 24 * 60 * 60;
43
+ const productSearchOperationId = 'bookingsProductSearch';
44
+ const productSearchCacheDecisionEvent = 'bookingsProductSearch:cache:decision';
45
+ const legacyProductSearchCacheEvents = {
46
+ cache_saved: 'bookingsProductSearch:cache:save',
47
+ partial_refresh_skipped: 'bookingsProductSearch:cache:partialRefreshSkipped',
48
+ empty_refresh_skipped: 'bookingsProductSearch:cache:emptyRefreshSkipped',
49
+ empty_cache_saved: 'bookingsProductSearch:cache:emptyRefresh',
50
+ };
51
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
52
+
53
+ const productSearchCacheKey = ({ userId, hint }) => hash({ userId, hint, operationId: productSearchOperationId });
54
+
55
+ const productCount = value => R.pathOr([], ['products'], value).length;
56
+
57
+ const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.products);
58
+
59
+ const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
60
+
61
+ const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId, searchInput, optionId, forceRefresh, startedAt }, action, extra = {}) => {
62
+ if (!app.events || !app.events.emit) return;
63
+ const payload = {
64
+ action,
65
+ operationId: productSearchOperationId,
66
+ requestId,
67
+ pluginName: app.name,
68
+ userIdHash: safeHash(userId),
69
+ hintHash: safeHash(hint),
70
+ cacheKeyHash: safeHash(cacheKey),
71
+ forceRefresh: Boolean(forceRefresh),
72
+ hasSearchInput: Boolean((searchInput || '').trim()),
73
+ hasOptionId: Boolean(optionId && optionId.length),
74
+ elapsedMs: Date.now() - startedAt,
75
+ ...extra,
76
+ };
77
+ app.events.emit(productSearchCacheDecisionEvent, payload);
78
+ if (legacyProductSearchCacheEvents[action]) {
79
+ app.events.emit(legacyProductSearchCacheEvents[action], payload);
80
+ }
81
+ };
82
+
83
+ const createLockRenewal = ({ app, key, value, ttl, onDecision }) => {
84
+ if (!app.cache.expireIfValue || ttl <= 0) return { stop: async () => {} };
85
+
86
+ const renewEveryMs = Math.max(250, Math.floor(ttl * 500));
87
+ let stopped = false;
88
+ let inFlightRenewal = Promise.resolve();
89
+ const renew = async () => {
90
+ if (stopped) return;
91
+ const renewed = await app.cache.expireIfValue({ key, value, ttl });
92
+ if (!renewed) {
93
+ stopped = true;
94
+ onDecision('lock_renew_lost', { reason: 'ownerTokenMismatch' });
95
+ }
96
+ };
97
+ const interval = setInterval(() => {
98
+ inFlightRenewal = renew().catch(err => {
99
+ stopped = true;
100
+ onDecision('lock_renew_failed', { reason: err.message });
101
+ });
102
+ }, renewEveryMs);
103
+ if (interval.unref) interval.unref();
104
+
105
+ return {
106
+ stop: async () => {
107
+ stopped = true;
108
+ clearInterval(interval);
109
+ await inFlightRenewal;
110
+ },
111
+ };
112
+ };
113
+
114
+ const createProductSearchUnavailableError = () => {
115
+ const err = new Error('Product search cache refresh did not produce cached results');
116
+ err.status = 503;
117
+ return err;
118
+ };
119
+
31
120
  const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
32
121
  const app = plugins.find(({ name }) => name === appKey);
33
122
  assert(app, 'could not find the app ' + appKey);
@@ -77,6 +166,7 @@ const bookingsCancel = plugins => async (req, res, next) => {
77
166
  } = req;
78
167
  try {
79
168
  const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
169
+ assert(app.cancelBooking, `cancelBooking is not available for ${appKey}`);
80
170
  const results = await app.cancelBooking({
81
171
  axios,
82
172
  token,
@@ -86,6 +176,46 @@ const bookingsCancel = plugins => async (req, res, next) => {
86
176
  hint,
87
177
  requestId: req.requestId,
88
178
  });
179
+ app.events.emit('bookingsCancelBooking', {
180
+ userId,
181
+ hint,
182
+ operationId: 'cancelBooking',
183
+ requestId: req.requestId,
184
+ pluginName: app.name,
185
+ payload: results,
186
+ });
187
+ return res.json(results);
188
+ } catch (err) {
189
+ return next(err);
190
+ }
191
+ };
192
+
193
+ const confirmBooking = plugins => async (req, res, next) => {
194
+ const {
195
+ axios,
196
+ params: { appKey, userId, hint },
197
+ body,
198
+ } = req;
199
+ try {
200
+ const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
201
+ assert(app.confirmBooking, `confirmBooking is not available for ${appKey}`);
202
+ const results = await app.confirmBooking({
203
+ axios,
204
+ token,
205
+ payload: body,
206
+ typeDefsAndQueries,
207
+ userId,
208
+ hint,
209
+ requestId: req.requestId,
210
+ });
211
+ app.events.emit('bookingsConfirmBooking', {
212
+ userId,
213
+ hint,
214
+ operationId: 'confirmBooking',
215
+ requestId: req.requestId,
216
+ pluginName: app.name,
217
+ payload: results,
218
+ });
89
219
  return res.json(results);
90
220
  } catch (err) {
91
221
  return next(err);
@@ -123,6 +253,22 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
123
253
  return filteredProducts;
124
254
  };
125
255
 
256
+ // Plugins may flag incomplete catalog responses with `catalogPartial` or `partial`;
257
+ // both must suppress cache writes so a partial result does not replace a complete cache.
258
+ const hasCacheableProductResults = pluginResults => Boolean(
259
+ pluginResults
260
+ && pluginResults.products
261
+ && pluginResults.products.length > 0
262
+ && !pluginResults.catalogPartial
263
+ && !pluginResults.partial
264
+ );
265
+
266
+ const hasNonEmptyProductCache = cacheContent => Boolean(
267
+ cacheContent
268
+ && cacheContent.products
269
+ && cacheContent.products.length > 0
270
+ );
271
+
126
272
  const $bookingsProductSearch = plugins => async ({
127
273
  axios,
128
274
  appKey,
@@ -149,7 +295,19 @@ const $bookingsProductSearch = plugins => async ({
149
295
  assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
150
296
  const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
151
297
 
152
- const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
298
+ const cacheKey = productSearchCacheKey({ userId, hint });
299
+ const telemetryContext = {
300
+ app,
301
+ cacheKey,
302
+ userId,
303
+ hint,
304
+ requestId,
305
+ searchInput,
306
+ optionId,
307
+ forceRefresh,
308
+ startedAt: Date.now(),
309
+ };
310
+ const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(telemetryContext, action, extra);
153
311
  const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
154
312
  const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
155
313
 
@@ -160,12 +318,75 @@ const $bookingsProductSearch = plugins => async ({
160
318
  const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
161
319
  const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
162
320
  const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
321
+ emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
322
+ cacheProductCount: productCount(initialActualCacheContent),
323
+ cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
324
+ ttrMs: ttr * 1000,
325
+ reason: hasPluginExecutionLock ? 'lockActive' : undefined,
326
+ });
327
+ assert(app.cache.saveIfNotExists, 'cache adapter must expose saveIfNotExists');
328
+
329
+ const getCachedProductSearchResults = async () => {
330
+ const cacheContent = await app.cache.get({ key: cacheKey });
331
+ if (hasProductCache(cacheContent)) return cacheContent;
332
+ return null;
333
+ };
334
+
335
+ const waitForProductSearchCache = async () => {
336
+ const timeoutAt = Date.now() + productSearchLockWaitMs;
337
+ while (Date.now() < timeoutAt) {
338
+ const cacheContent = await getCachedProductSearchResults();
339
+ if (cacheContent) return cacheContent;
340
+
341
+ const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
342
+ if (!lockStillActive) return null;
343
+
344
+ await sleep(productSearchLockPollMs);
345
+ }
346
+
347
+ return getCachedProductSearchResults();
348
+ };
349
+
350
+ const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
351
+ key: pluginExecutionLockKey,
352
+ value: lockOwnerToken,
353
+ ttl: getProductSearchLockTtlSeconds(),
354
+ });
163
355
 
164
356
  // Helper function to call the plugin, save cache, and return results
165
- const fetchFromPluginAndCache = async () => {
166
- await app.cache.save({ key: pluginExecutionLockKey, value: true, ttl: 120 });
357
+ const fetchFromPluginAndCache = async (reason = 'cache_miss') => {
358
+ const lockOwnerToken = crypto.randomBytes(16).toString('hex');
359
+ const lockStartedAt = Date.now();
360
+ const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
361
+ if (!lockAcquired) {
362
+ emitDecision('lock_wait', { reason, lockWaitMs: 0 });
363
+ const cacheContent = await waitForProductSearchCache();
364
+ const lockWaitMs = Date.now() - lockStartedAt;
365
+ if (cacheContent) {
366
+ emitDecision('cache_hit', {
367
+ reason: 'waitedForLeader',
368
+ lockWaitMs,
369
+ cacheProductCount: productCount(cacheContent),
370
+ });
371
+ return cacheContent;
372
+ }
373
+ emitDecision('lock_timeout', { reason, lockWaitMs });
374
+ throw createProductSearchUnavailableError();
375
+ }
376
+
377
+ const lockTtlSeconds = getProductSearchLockTtlSeconds();
378
+ emitDecision('lock_acquired', { reason, lockTtlSeconds });
379
+ const lockRenewal = createLockRenewal({
380
+ app,
381
+ key: pluginExecutionLockKey,
382
+ value: lockOwnerToken,
383
+ ttl: lockTtlSeconds,
384
+ onDecision: emitDecision,
385
+ });
386
+
167
387
  let pluginResults;
168
388
  try {
389
+ const pluginStartedAt = Date.now();
169
390
  pluginResults = await func({
170
391
  axios,
171
392
  token,
@@ -175,46 +396,72 @@ const $bookingsProductSearch = plugins => async ({
175
396
  userId,
176
397
  hint,
177
398
  });
399
+ const pluginElapsedMs = Date.now() - pluginStartedAt;
178
400
 
179
- // This function is responsible for caching if it fetched good results.
401
+ // This function is responsible for caching if it fetched usable results.
180
402
  // This applies to forceRefresh, initial load, or direct calls that result in a fetch.
181
403
  // The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
182
- if (pluginResults && pluginResults.products && pluginResults.products.length > 0) {
183
- const monthInSeconds = 30 * 24 * 60 * 60;
184
- await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
185
- await app.cache.save({ key: cacheKey, value: pluginResults, ttl: monthInSeconds });
186
- app.events.emit('bookingsProductSearch:cache:save', {
187
- cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
404
+ if (hasCacheableProductResults(pluginResults)) {
405
+ await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
406
+ await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
407
+ emitDecision('cache_saved', {
408
+ reason,
409
+ pluginElapsedMs,
410
+ cacheProductCount: productCount(pluginResults),
411
+ });
412
+ } else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
413
+ emitDecision('partial_refresh_skipped', {
414
+ reason: 'directPartialResultNotCached',
415
+ pluginElapsedMs,
416
+ cachePreserved: false,
417
+ returnedProductCount: productCount(pluginResults),
418
+ });
419
+ } else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
420
+ // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
421
+ await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
422
+ emitDecision('empty_cache_saved', {
423
+ reason,
424
+ pluginElapsedMs,
425
+ cacheProductCount: 0,
188
426
  });
189
427
  }
190
- // If pluginResults are empty, cache is NOT updated here by fetchFromPluginAndCache.
191
- // This maintains existing behavior for forceRefresh/initial load paths.
192
- // $updateProductSearchCache has its own logic for handling empty results from background refresh.
193
428
  } finally {
194
- await app.cache.drop({ key: pluginExecutionLockKey });
429
+ await lockRenewal.stop();
430
+ if (app.cache.dropIfValue) {
431
+ await app.cache.dropIfValue({ key: pluginExecutionLockKey, value: lockOwnerToken });
432
+ } else {
433
+ await app.cache.drop({ key: pluginExecutionLockKey });
434
+ }
195
435
  }
196
436
  return pluginResults || { products: [] }; // Ensure products array exists
197
437
  };
198
438
 
199
439
  // 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
200
440
  if (doNotCallPluginForProducts && !forceRefresh) {
201
- if (initialActualCacheContent && initialActualCacheContent.products) {
441
+ if (hasProductCache(initialActualCacheContent)) {
202
442
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
443
+ emitDecision('cache_hit', {
444
+ reason: 'doNotCallPluginForProducts',
445
+ cacheProductCount: initialActualCacheContent.products.length,
446
+ returnedProductCount: searchResults.length,
447
+ });
203
448
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
204
449
  }
450
+ emitDecision('cache_miss', { reason: 'doNotCallPluginForProducts' });
205
451
  return { products: [], ...(token.configuration || {}) };
206
452
  }
207
453
 
208
454
  // 2. `forceRefresh` is true (and not case 1): Fetch from plugin.
209
455
  // fetchFromPluginAndCache will handle caching the new results.
210
456
  if (forceRefresh) {
211
- const funcResults = await fetchFromPluginAndCache();
457
+ emitDecision('force_refresh');
458
+ const funcResults = await fetchFromPluginAndCache('force_refresh');
212
459
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
213
460
  return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
214
461
  }
215
462
 
216
463
  // 3. Cache exists (initialActualCacheContent) and not forceRefresh:
217
- if (initialActualCacheContent && initialActualCacheContent.products) {
464
+ if (hasProductCache(initialActualCacheContent)) {
218
465
  const cacheIsEmpty = !initialActualCacheContent.products.length;
219
466
  const trimmedSearch = (searchInput || '').trim();
220
467
  const searchFilterIsEmpty = (!trimmedSearch || trimmedSearch === '*') && !(optionId && optionId.length);
@@ -222,8 +469,15 @@ const $bookingsProductSearch = plugins => async ({
222
469
  // If cache is empty and no search filter, skip to case 4 to fetch fresh data
223
470
  const shouldSkipEmptyCache = cacheIsEmpty && searchFilterIsEmpty;
224
471
  if (!shouldSkipEmptyCache) {
225
- const returnCachedResults = () => {
472
+ const returnCachedResults = (action = 'cache_hit', extra = {}) => {
226
473
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
474
+ emitDecision(action, {
475
+ cacheProductCount: initialActualCacheContent.products.length,
476
+ returnedProductCount: searchResults.length,
477
+ cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
478
+ ttrMs: ttr * 1000,
479
+ ...extra,
480
+ });
227
481
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
228
482
  };
229
483
 
@@ -231,18 +485,20 @@ const $bookingsProductSearch = plugins => async ({
231
485
 
232
486
  // Cache is fresh or plugin execution in progress: serve from cache
233
487
  if (!isEffectivelyStale || hasPluginExecutionLock) {
234
- return returnCachedResults();
488
+ return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
235
489
  }
236
490
 
237
- // Cache is stale - check if background job already queued
238
- const hasJobQueueLock = await app.cache.get({ key: jobQueueLockKey });
239
- if (hasJobQueueLock) {
240
- return returnCachedResults();
491
+ // Cache is stale - queue at most one background refresh job and serve stale data.
492
+ const jobQueueLockAcquired = await app.cache.saveIfNotExists({
493
+ key: jobQueueLockKey,
494
+ value: true,
495
+ ttl: 60,
496
+ });
497
+ if (!jobQueueLockAcquired) {
498
+ return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
241
499
  }
242
500
 
243
- // Queue background refresh job and serve stale data
244
- await app.cache.save({ key: jobQueueLockKey, value: true, ttl: 60 });
245
- await addJob({
501
+ const jobId = await addJob({
246
502
  type: 'plugin',
247
503
  pluginName: appKey,
248
504
  method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
@@ -255,13 +511,16 @@ const $bookingsProductSearch = plugins => async ({
255
511
  },
256
512
  }, { removeOnComplete: true });
257
513
 
258
- return returnCachedResults();
514
+ return returnCachedResults('stale_served', {
515
+ reason: 'backgroundRefreshQueued',
516
+ jobId,
517
+ });
259
518
  }
260
519
  }
261
520
 
262
521
  // 4. No cache content (and not caught by previous conditions like forceRefresh or doNotCallPluginForProducts):
263
522
  // Fetch from plugin. fetchFromPluginAndCache will handle caching.
264
- const funcResults = await fetchFromPluginAndCache();
523
+ const funcResults = await fetchFromPluginAndCache('cache_miss');
265
524
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
266
525
  return {
267
526
  ...funcResults,
@@ -550,28 +809,72 @@ const $updateProductSearchCache = plugins => async ({
550
809
  return; // Or throw error
551
810
  }
552
811
 
553
- const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
554
- const monthInSeconds = 30 * 24 * 60 * 60;
812
+ const cacheKey = productSearchCacheKey({ userId, hint });
813
+ const markRefreshAttempted = () => app.cache.save({
814
+ key: `${cacheKey}:lastUpdated`,
815
+ value: Date.now(),
816
+ ttl: productSearchCacheTtlSeconds,
817
+ });
818
+ const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
819
+ app,
820
+ cacheKey,
821
+ userId,
822
+ hint,
823
+ requestId,
824
+ searchInput: '',
825
+ optionId: '',
826
+ forceRefresh: false,
827
+ startedAt: Date.now(),
828
+ }, action, extra);
829
+
830
+ if (hasCacheableProductResults(pluginResult)) {
831
+ await markRefreshAttempted();
832
+ await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
833
+ emitCacheEvent('cache_saved', { cacheProductCount: productCount(pluginResult) });
834
+ } else {
835
+ const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
836
+ const existingCacheContent = await app.cache.get({ key: cacheKey });
837
+ if (hasNonEmptyProductCache(existingCacheContent)) {
838
+ await markRefreshAttempted();
839
+ const eventExtra = {
840
+ reason: isPartialRefresh ? 'partialResultPreservedExistingCache' : 'emptyResultPreservedExistingCache',
841
+ cachePreserved: true,
842
+ existingProductCount: existingCacheContent.products.length,
843
+ };
844
+ if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
845
+ emitCacheEvent(
846
+ isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
847
+ eventExtra,
848
+ );
849
+ return;
850
+ }
851
+
852
+ if (isPartialRefresh) {
853
+ await markRefreshAttempted();
854
+ emitCacheEvent('partial_refresh_skipped', {
855
+ reason: 'partialResultNotCached',
856
+ cachePreserved: false,
857
+ returnedProductCount: productCount(pluginResult),
858
+ });
859
+ return;
860
+ }
555
861
 
556
- // Always update lastUpdated to prevent immediate re-trigger of background jobs
557
- // even if the plugin returned empty results.
558
- await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
862
+ // Best-effort race guard: avoid overwriting a non-empty cache written after the first read.
863
+ const latestCacheContent = await app.cache.get({ key: cacheKey });
864
+ if (hasNonEmptyProductCache(latestCacheContent)) {
865
+ await markRefreshAttempted();
866
+ emitCacheEvent('empty_refresh_skipped', {
867
+ reason: 'emptyResultPreservedConcurrentCache',
868
+ cachePreserved: true,
869
+ existingProductCount: latestCacheContent.products.length,
870
+ });
871
+ return;
872
+ }
559
873
 
560
- if (pluginResult && pluginResult.products && pluginResult.products.length > 0) {
561
- await app.cache.save({ key: cacheKey, value: pluginResult, ttl: monthInSeconds });
562
- app.events.emit('bookingsProductSearch:cache:save', {
563
- cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
564
- });
565
- console.log(`[$updateProductSearchCache][requestId: ${requestId}] Saved products to cache for ${appKey}, user ${userId}, hint ${hint}.`);
566
- } else {
567
- // If plugin returned no products, update the cache to reflect this.
568
- // This prevents serving stale data indefinitely if the source truly has no products anymore.
569
- await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: monthInSeconds });
570
- app.events.emit('bookingsProductSearch:cache:emptyRefresh', {
571
- cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
572
- pluginResult, // Log what the plugin returned
573
- });
574
- console.log(`[$updateProductSearchCache][requestId: ${requestId}] Plugin returned empty/no products. Cached empty for ${appKey}, user ${userId}, hint ${hint}.`);
874
+ // Empty complete refreshes are authoritative only when there is no existing non-empty cache.
875
+ await markRefreshAttempted();
876
+ await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
877
+ emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
575
878
  }
576
879
  };
577
880
 
@@ -580,6 +883,7 @@ const controllerFactory = plugins => {
580
883
  const controllerFunctions = {
581
884
  bookingsSearch: bookingsSearch(plugins),
582
885
  bookingsCancel: bookingsCancel(plugins),
886
+ confirmBooking: confirmBooking(plugins),
583
887
  $bookingsProductSearch: $bookingsProductSearch(plugins),
584
888
  bookingsProductSearch: bookingsProductSearch(plugins),
585
889
  getProductPackages: getProductPackages(plugins),
package/index.js CHANGED
@@ -90,6 +90,9 @@ module.exports = async ({
90
90
  get: args => cache.get({ ...args, pluginName }),
91
91
  getOrExec: args => cache.getOrExec({ ...args, pluginName }),
92
92
  save: args => cache.save({ ...args, pluginName }),
93
+ saveIfNotExists: args => cache.saveIfNotExists({ ...args, pluginName }),
94
+ expireIfValue: args => cache.expireIfValue({ ...args, pluginName }),
95
+ dropIfValue: args => cache.dropIfValue({ ...args, pluginName }),
93
96
  scan: args => cache.scan({ ...args, pluginName }),
94
97
  },
95
98
  axios: pluginAxios,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.115",
3
+ "version": "1.0.118",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/test/plugin.js CHANGED
@@ -75,6 +75,21 @@ class Plugin {
75
75
  });
76
76
  this.searchQuote = jestPlugin.fn(() => ({ quote: [{ id: chance.guid() }] }));
77
77
  this.createBooking = jestPlugin.fn(() => {});
78
+ this.cancelBooking = jestPlugin.fn(() => ({
79
+ cancelServicesReply: {},
80
+ cancellation: {
81
+ id: chance.guid(),
82
+ status: 'Cancelled',
83
+ },
84
+ }));
85
+ this.confirmBooking = jestPlugin.fn(() => ({
86
+ confirmBookingReply: {},
87
+ booking: {
88
+ id: chance.guid(),
89
+ status: 'OK',
90
+ serviceLines: [],
91
+ },
92
+ }));
78
93
  this.getAffiliateAgents = jestPlugin.fn(() => ({ agents: [] }));
79
94
  this.searchItineraries = jestPlugin.fn(() => ({ bookings: [] }));
80
95
  this.searchProductsForItinerary = jestPlugin.fn(() => ({ products: [] }));
@@ -535,6 +550,32 @@ class Plugin {
535
550
  */
536
551
  createBooking() {}
537
552
 
553
+ /**
554
+ * Cancel a booking
555
+ * @async
556
+ * @param {Object} args - Cancel booking arguments.
557
+ * @param {Object} args.token - A token definition, it's content varies between integrations.
558
+ * @param {Object} args.payload - Booking identifier payload.
559
+ * @returns {object} retVal - the return object.
560
+ */
561
+ cancelBooking() {}
562
+
563
+ /**
564
+ * Confirm a quote as a booking
565
+ * @async
566
+ * @param {Object} args - Confirm booking arguments.
567
+ * @param {Object} args.token - A token definition, it's content varies between integrations.
568
+ * @param {Object} args.payload - Booking identifier payload.
569
+ * @param {string} [args.payload.bookingId] - Booking id to confirm.
570
+ * @param {string} [args.payload.id] - Booking id to confirm.
571
+ * @param {string} [args.payload.ref] - Booking reference to confirm.
572
+ * @param {string} [args.payload.reference] - Booking reference to confirm.
573
+ * @returns {object} retVal - the return object.
574
+ * @returns {Object} retVal.confirmBookingReply - Raw integration reply.
575
+ * @returns {Booking} retVal.booking - A normalized Booking object.
576
+ */
577
+ confirmBooking() {}
578
+
538
579
  /**
539
580
  * @typedef {Object} Passenger
540
581
  * @property {string} firstName - Passenger first name
@@ -39,6 +39,8 @@ We are using a set of standardized methods to maintain compatibility; the follow
39
39
  * [searchAvailability]{@link Plugin#searchAvailability}
40
40
  * [searchQuote]{@link Plugin#searchQuote}
41
41
  * [createBooking]{@link Plugin#createBooking}
42
+ * [cancelBooking]{@link Plugin#cancelBooking}
43
+ * [confirmBooking]{@link Plugin#confirmBooking}
42
44
  * [searchProductsForItinerary]{@link Plugin#searchProductsForItinerary}
43
45
  * [searchAvailabilityForItinerary]{@link Plugin#searchAvailabilityForItinerary}
44
46
  * [addServiceToItinerary]{@link Plugin#addServiceToItinerary}
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