@app-connect/core 0.0.1

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.
@@ -0,0 +1,53 @@
1
+ const Mixpanel = require('mixpanel');
2
+ const parser = require('ua-parser-js');
3
+ let packageJson = null;
4
+ try {
5
+ packageJson = require('../package.json');
6
+ }
7
+ catch (e) {
8
+ packageJson = require('../../package.json');
9
+ }
10
+ const appName = 'App Connect';
11
+ const defaultEventAddedVia = 'server';
12
+ const version = packageJson.version;
13
+ let mixpanel = null;
14
+
15
+ exports.init = function init() {
16
+ if (!process.env.MIXPANEL_TOKEN) {
17
+ return;
18
+ }
19
+ mixpanel = Mixpanel.init(process.env.MIXPANEL_TOKEN);
20
+ }
21
+
22
+ exports.track = function track({ eventName, interfaceName, adapterName, accountId, extensionId, success, requestDuration, userAgent, ip, author, eventAddedVia, extras = null }) {
23
+ if (!mixpanel || !extensionId) {
24
+ return;
25
+ }
26
+ const inUseEventAddedVia = eventAddedVia || defaultEventAddedVia;
27
+ mixpanel.people.set_once(extensionId, {
28
+ version,
29
+ appName,
30
+ crmPlatform: adapterName
31
+ });
32
+ const ua = parser(userAgent);
33
+ mixpanel.track(eventName, {
34
+ distinct_id: extensionId,
35
+ interfaceName,
36
+ adapterName,
37
+ rcAccountId: accountId,
38
+ extensionId,
39
+ success,
40
+ requestDuration,
41
+ collectedFrom: 'server',
42
+ version,
43
+ appName,
44
+ eventAddedVia: inUseEventAddedVia,
45
+ $browser: ua.browser.name,
46
+ $os: ua.os.name,
47
+ $device: ua.device.type,
48
+ ip,
49
+ author,
50
+ ...extras
51
+ });
52
+ console.log(`Event: ${eventName}`);
53
+ }
@@ -0,0 +1,452 @@
1
+ const moment = require('moment-timezone');
2
+ const { secondsToHoursMinutesSeconds } = require('./util');
3
+ const adapterRegistry = require('../adapter/registry');
4
+
5
+ /**
6
+ * Centralized call log composition module
7
+ * Supports both plain text and HTML formats used across different CRM adapters
8
+ */
9
+
10
+ // Format types
11
+ const FORMAT_TYPES = {
12
+ PLAIN_TEXT: 'plainText',
13
+ HTML: 'html'
14
+ };
15
+
16
+ /**
17
+ * Compose call log details based on user settings and format type
18
+ * @param {Object} params - Composition parameters
19
+ * @param {string} params.logFormat - logFormat type: 'plainText' or 'html'
20
+ * @param {string} params.existingBody - Existing log body (for updates)
21
+ * @param {Object} params.callLog - Call log information
22
+ * @param {Object} params.contactInfo - Contact information
23
+ * @param {Object} params.user - User information
24
+ * @param {string} params.note - User note
25
+ * @param {string} params.aiNote - AI generated note
26
+ * @param {string} params.transcript - Call transcript
27
+ * @param {string} params.recordingLink - Recording link
28
+ * @param {string} params.subject - Call subject
29
+ * @param {Date} params.startTime - Call start time
30
+ * @param {number} params.duration - Call duration in seconds
31
+ * @param {string} params.result - Call result
32
+ * @returns {Promise<string>} Composed log body
33
+ */
34
+ async function composeCallLog(params) {
35
+ const {
36
+ logFormat = FORMAT_TYPES.PLAIN_TEXT,
37
+ existingBody = '',
38
+ callLog,
39
+ contactInfo,
40
+ user,
41
+ note,
42
+ aiNote,
43
+ transcript,
44
+ recordingLink,
45
+ subject,
46
+ startTime,
47
+ duration,
48
+ result,
49
+ platform
50
+ } = params;
51
+
52
+ let body = existingBody;
53
+ const userSettings = user.userSettings || {};
54
+ // Determine timezone handling
55
+ let resolvedStartTime = startTime || callLog?.startTime;
56
+ let timezoneOffset = user.timezoneOffset;
57
+ if (resolvedStartTime) {
58
+ resolvedStartTime = moment(resolvedStartTime);
59
+ }
60
+ // Apply upsert functions based on user settings
61
+ if (note && (userSettings?.addCallLogNote?.value ?? true)) {
62
+ body = upsertCallAgentNote({ body, note, logFormat });
63
+ }
64
+
65
+ if (callLog?.sessionId && (userSettings?.addCallSessionId?.value ?? false)) {
66
+ body = upsertCallSessionId({ body, id: callLog.sessionId, logFormat });
67
+ }
68
+
69
+ if (subject && (userSettings?.addCallLogSubject?.value ?? true)) {
70
+ body = upsertCallSubject({ body, subject, logFormat });
71
+ }
72
+
73
+ if (contactInfo?.phoneNumber && (userSettings?.addCallLogContactNumber?.value ?? false)) {
74
+ body = upsertContactPhoneNumber({
75
+ body,
76
+ phoneNumber: contactInfo.phoneNumber,
77
+ direction: callLog?.direction,
78
+ logFormat
79
+ });
80
+ }
81
+
82
+ if (resolvedStartTime && (userSettings?.addCallLogDateTime?.value ?? true)) {
83
+ body = upsertCallDateTime({
84
+ body,
85
+ startTime: resolvedStartTime,
86
+ timezoneOffset,
87
+ logFormat
88
+ });
89
+ }
90
+
91
+ if (duration && (userSettings?.addCallLogDuration?.value ?? true)) {
92
+ body = upsertCallDuration({ body, duration, logFormat });
93
+ }
94
+
95
+ if (result && (userSettings?.addCallLogResult?.value ?? true)) {
96
+ body = upsertCallResult({ body, result, logFormat });
97
+ }
98
+
99
+ if (recordingLink && (userSettings?.addCallLogRecording?.value ?? true)) {
100
+ body = upsertCallRecording({ body, recordingLink, logFormat });
101
+ }
102
+
103
+ if (aiNote && (userSettings?.addCallLogAINote?.value ?? true)) {
104
+ body = upsertAiNote({ body, aiNote, logFormat });
105
+ }
106
+
107
+ if (transcript && (userSettings?.addCallLogTranscript?.value ?? true)) {
108
+ body = upsertTranscript({ body, transcript, logFormat });
109
+ }
110
+
111
+ return body;
112
+ }
113
+
114
+ /**
115
+ * Upsert functions for different log components
116
+ */
117
+
118
+ function upsertCallAgentNote({ body, note, logFormat }) {
119
+ if (!note) return body;
120
+ if (logFormat === FORMAT_TYPES.HTML) {
121
+ // HTML logFormat with proper Agent notes section handling
122
+ const noteRegex = RegExp('<b>Agent notes</b>([\\s\\S]+?)Call details</b>');
123
+ if (noteRegex.test(body)) {
124
+ return body.replace(noteRegex, `<b>Agent notes</b><br>${note}<br><br><b>Call details</b>`);
125
+ }
126
+ else {
127
+ return `<b>Agent notes</b><br>${note}<br><br><b>Call details</b><br>` + body;
128
+ }
129
+ } else {
130
+ // Plain text logFormat - FIXED REGEX for multi-line notes with blank lines
131
+ const noteRegex = /- (?:Note|Agent notes): ([\s\S]*?)(?=\n- [A-Z][a-zA-Z\s/]*:|\n$|$)/;
132
+ if (noteRegex.test(body)) {
133
+ return body.replace(noteRegex, `- Note: ${note}`);
134
+ } else {
135
+ return `- Note: ${note}\n` + body;
136
+ }
137
+ }
138
+ }
139
+
140
+ function upsertCallSessionId({ body, id, logFormat }) {
141
+ if (!id) return body;
142
+
143
+ if (logFormat === FORMAT_TYPES.HTML) {
144
+ // More flexible regex that handles both <li> wrapped and unwrapped content
145
+ const idRegex = /(?:<li>)?<b>Session Id<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
146
+ if (idRegex.test(body)) {
147
+ return body.replace(idRegex, `<li><b>Session Id</b>: ${id}</li>`);
148
+ } else {
149
+ return body + `<li><b>Session Id</b>: ${id}</li>`;
150
+ }
151
+ } else {
152
+ // Match Session Id field and any trailing newlines, replace with single newline
153
+ const sessionIdRegex = /- Session Id: [^\n]*\n*/;
154
+ if (sessionIdRegex.test(body)) {
155
+ return body.replace(sessionIdRegex, `- Session Id: ${id}\n`);
156
+ } else {
157
+ return body + `- Session Id: ${id}\n`;
158
+ }
159
+ }
160
+ }
161
+
162
+ function upsertCallSubject({ body, subject, logFormat }) {
163
+ if (!subject) return body;
164
+
165
+ if (logFormat === FORMAT_TYPES.HTML) {
166
+ // More flexible regex that handles both <li> wrapped and unwrapped content
167
+ const subjectRegex = /(?:<li>)?<b>Summary<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
168
+ if (subjectRegex.test(body)) {
169
+ return body.replace(subjectRegex, `<li><b>Summary</b>: ${subject}</li>`);
170
+ } else {
171
+ return body + `<li><b>Summary</b>: ${subject}</li>`;
172
+ }
173
+ } else {
174
+ // Match Summary field and any trailing newlines, replace with single newline
175
+ const subjectRegex = /- Summary: [^\n]*\n*/;
176
+ if (subjectRegex.test(body)) {
177
+ return body.replace(subjectRegex, `- Summary: ${subject}\n`);
178
+ } else {
179
+ return body + `- Summary: ${subject}\n`;
180
+ }
181
+ }
182
+ }
183
+
184
+ function upsertContactPhoneNumber({ body, phoneNumber, direction, logFormat }) {
185
+ if (!phoneNumber) return body;
186
+
187
+ const label = direction === 'Outbound' ? 'Recipient' : 'Caller';
188
+ let result = body;
189
+
190
+ if (logFormat === FORMAT_TYPES.HTML) {
191
+ // More flexible regex that handles both <li> wrapped and unwrapped content
192
+ const phoneNumberRegex = new RegExp(`(?:<li>)?<b>${label} phone number</b>:\\s*([^<\\n]+)(?:</li>|(?=<|$))`, 'i');
193
+ if (phoneNumberRegex.test(result)) {
194
+ result = result.replace(phoneNumberRegex, `<li><b>${label} phone number</b>: ${phoneNumber}</li>`);
195
+ } else {
196
+ result += `<li><b>${label} phone number</b>: ${phoneNumber}</li>`;
197
+ }
198
+ } else {
199
+ // More flexible regex that handles both with and without newlines
200
+ const phoneNumberRegex = /- Contact Number: ([^\n-]+)(?=\n-|\n|$)/;
201
+ if (phoneNumberRegex.test(result)) {
202
+ result = result.replace(phoneNumberRegex, `- Contact Number: ${phoneNumber}\n`);
203
+ } else {
204
+ result += `- Contact Number: ${phoneNumber}\n`;
205
+ }
206
+ }
207
+ return result;
208
+ }
209
+
210
+ function upsertCallDateTime({ body, startTime, timezoneOffset, logFormat }) {
211
+ if (!startTime) return body;
212
+
213
+ // Simple approach: convert to moment and apply timezone offset
214
+ let momentTime = moment(startTime);
215
+ if (timezoneOffset) {
216
+ // Handle both string offsets ('+05:30') and numeric offsets (330 minutes or 5.5 hours)
217
+ if (typeof timezoneOffset === 'string' && timezoneOffset.includes(':')) {
218
+ // String logFormat like '+05:30' or '-05:00'
219
+ momentTime = momentTime.utcOffset(timezoneOffset);
220
+ } else {
221
+ // Numeric logFormat (minutes or hours)
222
+ momentTime = momentTime.utcOffset(Number(timezoneOffset));
223
+ }
224
+ }
225
+ const formattedDateTime = momentTime.format('YYYY-MM-DD hh:mm:ss A');
226
+ let result = body;
227
+
228
+ if (logFormat === FORMAT_TYPES.HTML) {
229
+ // More flexible regex that handles both <li> wrapped and unwrapped content
230
+ const dateTimeRegex = /(?:<li>)?<b>Date\/time<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
231
+ if (dateTimeRegex.test(result)) {
232
+ result = result.replace(dateTimeRegex, `<li><b>Date/time</b>: ${formattedDateTime}</li>`);
233
+ } else {
234
+ result += `<li><b>Date/time</b>: ${formattedDateTime}</li>`;
235
+ }
236
+ } else {
237
+ // Handle duplicated Date/Time entries and match complete date/time values
238
+ const dateTimeRegex = /(?:- Date\/Time: [^-]*(?:-[^-]*)*)+/;
239
+ if (dateTimeRegex.test(result)) {
240
+ result = result.replace(dateTimeRegex, `- Date/Time: ${formattedDateTime}\n`);
241
+ } else {
242
+ result += `- Date/Time: ${formattedDateTime}\n`;
243
+ }
244
+ }
245
+ return result;
246
+ }
247
+
248
+ function upsertCallDuration({ body, duration, logFormat }) {
249
+ if (!duration) return body;
250
+
251
+ const formattedDuration = secondsToHoursMinutesSeconds(duration);
252
+ let result = body;
253
+
254
+ if (logFormat === FORMAT_TYPES.HTML) {
255
+ // More flexible regex that handles both <li> wrapped and unwrapped content
256
+ const durationRegex = /(?:<li>)?<b>Duration<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
257
+ if (durationRegex.test(result)) {
258
+ result = result.replace(durationRegex, `<li><b>Duration</b>: ${formattedDuration}</li>`);
259
+ } else {
260
+ result += `<li><b>Duration</b>: ${formattedDuration}</li>`;
261
+ }
262
+ } else {
263
+ // More flexible regex that handles both with and without newlines
264
+ const durationRegex = /- Duration: ([^\n-]+)(?=\n-|\n|$)/;
265
+ if (durationRegex.test(result)) {
266
+ result = result.replace(durationRegex, `- Duration: ${formattedDuration}\n`);
267
+ } else {
268
+ result += `- Duration: ${formattedDuration}\n`;
269
+ }
270
+ }
271
+ return result;
272
+ }
273
+
274
+ function upsertCallResult({ body, result, logFormat }) {
275
+ if (!result) return body;
276
+
277
+ let bodyResult = body;
278
+
279
+ if (logFormat === FORMAT_TYPES.HTML) {
280
+ // More flexible regex that handles both <li> wrapped and unwrapped content
281
+ const resultRegex = /(?:<li>)?<b>Result<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
282
+ if (resultRegex.test(bodyResult)) {
283
+ bodyResult = bodyResult.replace(resultRegex, `<li><b>Result</b>: ${result}</li>`);
284
+ } else {
285
+ bodyResult += `<li><b>Result</b>: ${result}</li>`;
286
+ }
287
+ } else {
288
+ // More flexible regex that handles both with and without newlines
289
+ const resultRegex = /- Result: ([^\n-]+)(?=\n-|\n|$)/;
290
+ if (resultRegex.test(bodyResult)) {
291
+ bodyResult = bodyResult.replace(resultRegex, `- Result: ${result}\n`);
292
+ } else {
293
+ bodyResult += `- Result: ${result}\n`;
294
+ }
295
+ }
296
+ return bodyResult;
297
+ }
298
+
299
+ function upsertCallRecording({ body, recordingLink, logFormat }) {
300
+ // console.log({ m: "upsertCallRecording", recordingLink, hasBody: !!body, logFormat, bodyLength: body?.length });
301
+ if (!recordingLink) return body;
302
+
303
+ let result = body;
304
+
305
+ if (logFormat === FORMAT_TYPES.HTML) {
306
+ // More flexible regex that handles both <li> wrapped and unwrapped content
307
+ const recordingLinkRegex = /(?:<li>)?<b>Call recording link<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
308
+ if (recordingLink) {
309
+ if (recordingLinkRegex.test(result)) {
310
+ if (recordingLink.startsWith('http')) {
311
+ result = result.replace(recordingLinkRegex, `<li><b>Call recording link</b>: <a target="_blank" href="${recordingLink}">open</a></li>`);
312
+ } else {
313
+ result = result.replace(recordingLinkRegex, `<li><b>Call recording link</b>: (pending...)</li>`);
314
+ }
315
+ } else {
316
+ let text = '';
317
+ if (recordingLink.startsWith('http')) {
318
+ text = `<li><b>Call recording link</b>: <a target="_blank" href="${recordingLink}">open</a></li>`;
319
+ } else {
320
+ text = '<li><b>Call recording link</b>: (pending...)</li>';
321
+ }
322
+ if (result.indexOf('</ul>') === -1) {
323
+ result += text;
324
+ } else {
325
+ result = result.replace('</ul>', `${text}</ul>`);
326
+ }
327
+ }
328
+ }
329
+ } else {
330
+ // Match recording link field and any trailing content, replace with single newline
331
+ const recordingLinkRegex = /- Call recording link: [^\n]*\n*/;
332
+ if (recordingLinkRegex.test(result)) {
333
+ result = result.replace(recordingLinkRegex, `- Call recording link: ${recordingLink}\n`);
334
+ } else {
335
+ if (result && !result.endsWith('\n')) {
336
+ result += '\n';
337
+ }
338
+ result += `- Call recording link: ${recordingLink}\n`;
339
+ }
340
+ }
341
+ return result;
342
+ }
343
+
344
+ function upsertAiNote({ body, aiNote, logFormat }) {
345
+ if (!aiNote) return body;
346
+
347
+ const clearedAiNote = aiNote.replace(/\n+$/, '');
348
+ let result = body;
349
+
350
+ if (logFormat === FORMAT_TYPES.HTML) {
351
+ const formattedAiNote = clearedAiNote.replace(/(?:\r\n|\r|\n)/g, '<br>');
352
+ const aiNoteRegex = /<div><b>AI Note<\/b><br>(.+?)<\/div>/;
353
+ if (aiNoteRegex.test(result)) {
354
+ result = result.replace(aiNoteRegex, `<div><b>AI Note</b><br>${formattedAiNote}</div>`);
355
+ } else {
356
+ result += `<div><b>AI Note</b><br>${formattedAiNote}</div><br>`;
357
+ }
358
+ } else {
359
+ const aiNoteRegex = /- AI Note:([\s\S]*?)--- END/;
360
+ if (aiNoteRegex.test(result)) {
361
+ result = result.replace(aiNoteRegex, `- AI Note:\n${clearedAiNote}\n--- END`);
362
+ } else {
363
+ result += `- AI Note:\n${clearedAiNote}\n--- END\n`;
364
+ }
365
+ }
366
+ return result;
367
+ }
368
+
369
+ function upsertTranscript({ body, transcript, logFormat }) {
370
+ if (!transcript) return body;
371
+
372
+ let result = body;
373
+
374
+ if (logFormat === FORMAT_TYPES.HTML) {
375
+ const formattedTranscript = transcript.replace(/(?:\r\n|\r|\n)/g, '<br>');
376
+ const transcriptRegex = /<div><b>Transcript<\/b><br>(.+?)<\/div>/;
377
+ if (transcriptRegex.test(result)) {
378
+ result = result.replace(transcriptRegex, `<div><b>Transcript</b><br>${formattedTranscript}</div>`);
379
+ } else {
380
+ result += `<div><b>Transcript</b><br>${formattedTranscript}</div><br>`;
381
+ }
382
+ } else {
383
+ const transcriptRegex = /- Transcript:([\s\S]*?)--- END/;
384
+ if (transcriptRegex.test(result)) {
385
+ result = result.replace(transcriptRegex, `- Transcript:\n${transcript}\n--- END`);
386
+ } else {
387
+ result += `- Transcript:\n${transcript}\n--- END\n`;
388
+ }
389
+ }
390
+ return result;
391
+ }
392
+
393
+ /**
394
+ * Helper function to determine format type for a CRM platform
395
+ * @param {string} platform - CRM platform name
396
+ * @returns {string} Format type
397
+ */
398
+ function getLogFormatType(platform) {
399
+ const manifest = adapterRegistry.getManifest(platform, true);
400
+ const platformConfig = manifest.platforms?.[platform];
401
+ return platformConfig?.logFormat;
402
+ }
403
+
404
+ /**
405
+ * Create a specialized composition function for specific CRM requirements
406
+ * @param {string} platform - CRM platform name
407
+ * @returns {Function} Customized composition function
408
+ */
409
+ function createComposer(platform) {
410
+ const logFormat = getLogFormatType(platform);
411
+
412
+ return async function (params) {
413
+ // Add platform-specific formatting
414
+ if (logFormat === FORMAT_TYPES.HTML && platform === 'pipedrive') {
415
+ // Pipedrive wraps call details in <ul> tags
416
+ const composed = await composeCallLog({ ...params, logFormat });
417
+ if (composed && !composed.includes('<ul>')) {
418
+ return `<b>Call details</b><ul>${composed}</ul>`;
419
+ }
420
+ return composed;
421
+ }
422
+
423
+ if (logFormat === FORMAT_TYPES.HTML && platform === 'bullhorn') {
424
+ // Bullhorn also wraps call details in <ul> tags
425
+ const composed = await composeCallLog({ ...params, logFormat });
426
+ if (composed && !composed.includes('<ul>')) {
427
+ return `<b>Call details</b><ul>${composed}</ul>`;
428
+ }
429
+ return composed;
430
+ }
431
+
432
+ return composeCallLog({ ...params, logFormat });
433
+ };
434
+ }
435
+
436
+ module.exports = {
437
+ composeCallLog,
438
+ createComposer,
439
+ getLogFormatType,
440
+ FORMAT_TYPES,
441
+ // Export individual upsert functions for backward compatibility
442
+ upsertCallAgentNote,
443
+ upsertCallSessionId,
444
+ upsertCallSubject,
445
+ upsertContactPhoneNumber,
446
+ upsertCallDateTime,
447
+ upsertCallDuration,
448
+ upsertCallResult,
449
+ upsertCallRecording,
450
+ upsertAiNote,
451
+ upsertTranscript
452
+ };
package/lib/encode.js ADDED
@@ -0,0 +1,30 @@
1
+ const crypto = require('crypto');
2
+
3
+ function getCipherKey() {
4
+ if (!process.env.APP_SERVER_SECRET_KEY) {
5
+ throw new Error('APP_SERVER_SECRET_KEY is not defined');
6
+ }
7
+ if (process.env.APP_SERVER_SECRET_KEY.length < 32) {
8
+ // pad secret key with spaces if it is less than 32 bytes
9
+ return process.env.APP_SERVER_SECRET_KEY.padEnd(32, ' ');
10
+ }
11
+ if (process.env.APP_SERVER_SECRET_KEY.length > 32) {
12
+ // truncate secret key if it is more than 32 bytes
13
+ return process.env.APP_SERVER_SECRET_KEY.slice(0, 32);
14
+ }
15
+ return process.env.APP_SERVER_SECRET_KEY;
16
+ }
17
+
18
+ function encode(data) {
19
+ const cipher = crypto.createCipheriv('aes-256-cbc', getCipherKey(), Buffer.alloc(16, 0));
20
+ return cipher.update(data, 'utf8', 'hex') + cipher.final('hex');
21
+ }
22
+
23
+ function decoded(encryptedData) {
24
+ const decipher = crypto.createDecipheriv('aes-256-cbc', getCipherKey(), Buffer.alloc(16, 0));
25
+ return decipher.update(encryptedData, 'hex', 'utf8') + decipher.final('utf8');
26
+ }
27
+
28
+
29
+ exports.encode = encode;
30
+ exports.decoded = decoded;
@@ -0,0 +1,42 @@
1
+ function rateLimitErrorMessage({ platform }) {
2
+ return {
3
+ message: `Rate limit exceeded`,
4
+ messageType: 'warning',
5
+ details: [
6
+ {
7
+ title: 'Details',
8
+ items: [
9
+ {
10
+ id: '1',
11
+ type: 'text',
12
+ text: `You have exceeded the maximum number of requests allowed by ${platform}. Please try again in the next minute. If the problem persists please contact support.`
13
+ }
14
+ ]
15
+ }
16
+ ],
17
+ ttl: 5000
18
+ }
19
+ }
20
+
21
+ function authorizationErrorMessage({ platform }) {
22
+ return {
23
+ message: `Authorization error`,
24
+ messageType: 'warning',
25
+ details: [
26
+ {
27
+ title: 'Details',
28
+ items: [
29
+ {
30
+ id: '1',
31
+ type: 'text',
32
+ text: `It seems like there's something wrong with your authorization of ${platform}. Please Logout and then Connect your ${platform} account within this extension.`
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ ttl: 5000
38
+ }
39
+ }
40
+
41
+ exports.rateLimitErrorMessage = rateLimitErrorMessage;
42
+ exports.authorizationErrorMessage = authorizationErrorMessage;
package/lib/jwt.js ADDED
@@ -0,0 +1,16 @@
1
+ const { sign, verify } = require('jsonwebtoken');
2
+
3
+ function generateJwt(data) {
4
+ return sign(data, process.env.APP_SERVER_SECRET_KEY, { expiresIn: '120y' })
5
+ }
6
+
7
+ function decodeJwt(token) {
8
+ try {
9
+ return verify(token, process.env.APP_SERVER_SECRET_KEY);
10
+ } catch (e) {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ exports.generateJwt = generateJwt;
16
+ exports.decodeJwt = decodeJwt;
package/lib/oauth.js ADDED
@@ -0,0 +1,85 @@
1
+ /* eslint-disable no-param-reassign */
2
+ const ClientOAuth2 = require('client-oauth2');
3
+ const { Lock } = require('../models/dynamo/lockSchema');
4
+ const { UserModel } = require('../models/userModel');
5
+ const adapterRegistry = require('../adapter/registry');
6
+
7
+ // oauthApp strategy is default to 'code' which use credentials to get accessCode, then exchange for accessToken and refreshToken.
8
+ // To change to other strategies, please refer to: https://github.com/mulesoft-labs/js-client-oauth2
9
+ function getOAuthApp({ clientId, clientSecret, accessTokenUri, authorizationUri, redirectUri, scopes }) {
10
+ return new ClientOAuth2({
11
+ clientId: clientId,
12
+ clientSecret: clientSecret,
13
+ accessTokenUri: accessTokenUri,
14
+ authorizationUri: authorizationUri,
15
+ redirectUri: redirectUri,
16
+ scopes: scopes
17
+ });
18
+ }
19
+
20
+
21
+ async function checkAndRefreshAccessToken(oauthApp, user, tokenLockTimeout = 10) {
22
+ const dateNow = new Date();
23
+ const tokenExpiry = new Date(user.tokenExpiry);
24
+ const expiryBuffer = 1000 * 60 * 2; // 2 minutes => 120000ms
25
+ // Special case: Bullhorn
26
+ if (user.platform) {
27
+ const platformModule = adapterRegistry.getAdapter(user.platform);
28
+ if (platformModule.checkAndRefreshAccessToken) {
29
+ return platformModule.checkAndRefreshAccessToken(oauthApp, user, tokenLockTimeout);
30
+ }
31
+ }
32
+ // Other CRMs
33
+ if (user && user.accessToken && user.refreshToken && tokenExpiry.getTime() < (dateNow.getTime() + expiryBuffer)) {
34
+ // case: use dynamoDB to manage token refresh lock
35
+ if (process.env.USE_TOKEN_REFRESH_LOCK === 'true') {
36
+ let lock = await Lock.get({ userId: user.id });
37
+ let newLock;
38
+ if (!!lock?.ttl && lock.ttl < dateNow.getTime()) {
39
+ await lock.delete();
40
+ lock = null;
41
+ }
42
+ if (lock) {
43
+ let processTime = 0;
44
+ while (!!lock && processTime < tokenLockTimeout) {
45
+ await new Promise(resolve => setTimeout(resolve, 2000)); // wait for 2 seconds
46
+ processTime += 2;
47
+ lock = await Lock.get({ userId: user.id });
48
+ }
49
+ // Timeout -> let users try another time
50
+ if (processTime >= tokenLockTimeout) {
51
+ throw new Error('Token lock timeout');
52
+ }
53
+ user = await UserModel.findByPk(user.id);
54
+ }
55
+ else {
56
+ newLock = await Lock.create({
57
+ userId: user.id
58
+ });
59
+ }
60
+ const token = oauthApp.createToken(user.accessToken, user.refreshToken);
61
+ const { accessToken, refreshToken, expires } = await token.refresh();
62
+ user.accessToken = accessToken;
63
+ user.refreshToken = refreshToken;
64
+ user.tokenExpiry = expires;
65
+ await user.save();
66
+ if (newLock) {
67
+ await newLock.delete();
68
+ }
69
+ }
70
+ // case: run withou token refresh lock
71
+ else {
72
+ const token = oauthApp.createToken(user.accessToken, user.refreshToken);
73
+ const { accessToken, refreshToken, expires } = await token.refresh();
74
+ user.accessToken = accessToken;
75
+ user.refreshToken = refreshToken;
76
+ user.tokenExpiry = expires;
77
+ await user.save();
78
+ }
79
+
80
+ }
81
+ return user;
82
+ }
83
+
84
+ exports.checkAndRefreshAccessToken = checkAndRefreshAccessToken;
85
+ exports.getOAuthApp = getOAuthApp;