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 +21 -1
- package/controllers/README.md +2 -1
- package/controllers/__tests__/bookings-searchProducts.js +106 -0
- package/controllers/__tests__/user.js +119 -10
- package/controllers/app.js +81 -44
- package/controllers/bookings.js +63 -15
- package/package.json +1 -1
- package/.windsurfrules +0 -9
- package/CLAUDE.md +0 -166
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/
|
|
885
|
+
$ref: '#/components/schemas/CreateAppTokenResponse'
|
|
866
886
|
delete:
|
|
867
887
|
tags:
|
|
868
888
|
- app
|
package/controllers/README.md
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 =>
|
|
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
|
|
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
|
|
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 =>
|
|
441
|
+
await Promise.all(originalRows.map(currentRow => db.query(
|
|
333
442
|
'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
|
|
334
443
|
{
|
|
335
444
|
replacements: {
|
package/controllers/app.js
CHANGED
|
@@ -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,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
152
|
-
existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
|
|
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),
|
|
186
|
+
await queue.getJob(existing.bullJobId),
|
|
161
187
|
);
|
|
162
188
|
if (bullCron !== job.cron) {
|
|
163
|
-
await queue.removeJobs(existing.bullJobId);
|
|
164
|
-
|
|
165
|
-
existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
|
|
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
|
-
|
|
171
|
-
const actualBullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
|
|
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(
|
|
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
|
};
|
package/controllers/bookings.js
CHANGED
|
@@ -50,7 +50,19 @@ const legacyProductSearchCacheEvents = {
|
|
|
50
50
|
};
|
|
51
51
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
52
52
|
|
|
53
|
-
const
|
|
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 = ({
|
|
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
|
-
} =
|
|
348
|
+
} = normalizedRequestBody;
|
|
312
349
|
|
|
313
|
-
//
|
|
314
|
-
const payloadForPlugin = {
|
|
315
|
-
|
|
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(
|
|
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', {
|
|
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
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.
|