@app-connect/core 1.7.5 → 1.7.8
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/handlers/admin.js +29 -17
- package/handlers/calldown.js +33 -0
- package/handlers/contact.js +20 -2
- package/index.js +348 -156
- package/lib/debugTracer.js +159 -0
- package/lib/oauth.js +28 -18
- package/lib/ringcentral.js +2 -2
- package/lib/s3ErrorLogReport.js +66 -0
- package/package.json +7 -5
- package/releaseNotes.json +44 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
class DebugTracer {
|
|
2
|
+
/**
|
|
3
|
+
* Creates a new DebugTracer instance
|
|
4
|
+
* @param {Object} headers - Request headers object
|
|
5
|
+
*/
|
|
6
|
+
constructor(headers = {}) {
|
|
7
|
+
this.traces = [];
|
|
8
|
+
this.startTime = Date.now();
|
|
9
|
+
this.requestId = this._generateRequestId();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generates a unique request ID for tracking
|
|
14
|
+
* @returns {string} Unique request ID
|
|
15
|
+
*/
|
|
16
|
+
_generateRequestId() {
|
|
17
|
+
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Captures the current stack trace
|
|
22
|
+
* @param {number} skipFrames - Number of frames to skip from the top (default: 2)
|
|
23
|
+
* @returns {string[]} Array of stack trace lines
|
|
24
|
+
*/
|
|
25
|
+
_captureStackTrace(skipFrames = 2) {
|
|
26
|
+
const err = new Error();
|
|
27
|
+
const stack = err.stack || '';
|
|
28
|
+
const lines = stack.split('\n').slice(skipFrames);
|
|
29
|
+
return lines.map(line => line.trim()).filter(line => line.startsWith('at '));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Records a trace entry with method name, data, and stack trace
|
|
34
|
+
* @param {string} methodName - Name of the method/function being traced
|
|
35
|
+
* @param {Object} data - Data to record (will be sanitized to remove sensitive info)
|
|
36
|
+
* @param {Object} options - Additional options
|
|
37
|
+
* @param {boolean} options.includeStack - Whether to include stack trace (default: true)
|
|
38
|
+
* @param {string} options.level - Log level: 'info', 'warn', 'error' (default: 'info')
|
|
39
|
+
* @returns {DebugTracer} Returns this for chaining
|
|
40
|
+
*/
|
|
41
|
+
trace(methodName, data = {}, options = {}) {
|
|
42
|
+
const { includeStack = true, level = 'info' } = options;
|
|
43
|
+
|
|
44
|
+
const traceEntry = {
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
elapsed: Date.now() - this.startTime,
|
|
47
|
+
methodName,
|
|
48
|
+
level,
|
|
49
|
+
data: this._sanitizeData(data)
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (includeStack) {
|
|
53
|
+
traceEntry.stackTrace = this._captureStackTrace(3);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.traces.push(traceEntry);
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Records an error trace with full stack information
|
|
62
|
+
* @param {string} methodName - Name of the method where error occurred
|
|
63
|
+
* @param {Error|string} error - Error object or message
|
|
64
|
+
* @param {Object} additionalData - Additional context data
|
|
65
|
+
* @returns {DebugTracer} Returns this for chaining
|
|
66
|
+
*/
|
|
67
|
+
traceError(methodName, error, additionalData = {}) {
|
|
68
|
+
const errorData = {
|
|
69
|
+
message: error instanceof Error ? error.message : String(error),
|
|
70
|
+
errorStack: error instanceof Error ? error.stack : null,
|
|
71
|
+
...additionalData
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return this.trace(methodName, errorData, { level: 'error' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Sanitizes data by removing sensitive fields
|
|
79
|
+
* @param {Object} data - Data to sanitize
|
|
80
|
+
* @returns {Object} Sanitized data
|
|
81
|
+
*/
|
|
82
|
+
_sanitizeData(data) {
|
|
83
|
+
if (!data || typeof data !== 'object') {
|
|
84
|
+
return data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sensitiveFields = [
|
|
88
|
+
'accessToken', 'refreshToken', 'apiKey', 'password',
|
|
89
|
+
'secret', 'token', 'authorization', 'auth', 'key',
|
|
90
|
+
'credential', 'credentials', 'privateKey', 'clientSecret'
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const sanitized = Array.isArray(data) ? [...data] : { ...data };
|
|
94
|
+
|
|
95
|
+
const sanitizeRecursive = (obj) => {
|
|
96
|
+
if (!obj || typeof obj !== 'object') {
|
|
97
|
+
return obj;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const key of Object.keys(obj)) {
|
|
101
|
+
const lowerKey = key.toLowerCase();
|
|
102
|
+
if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
|
|
103
|
+
// eslint-disable-next-line no-param-reassign
|
|
104
|
+
obj[key] = '[REDACTED]';
|
|
105
|
+
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
|
|
106
|
+
// eslint-disable-next-line no-param-reassign
|
|
107
|
+
obj[key] = sanitizeRecursive(
|
|
108
|
+
Array.isArray(obj[key]) ? [...obj[key]] : { ...obj[key] }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return obj;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return sanitizeRecursive(sanitized);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Gets the complete trace data for inclusion in response
|
|
120
|
+
* @returns {Object} Trace data object
|
|
121
|
+
*/
|
|
122
|
+
getTraceData() {
|
|
123
|
+
return {
|
|
124
|
+
requestId: this.requestId,
|
|
125
|
+
totalDuration: `${Date.now() - this.startTime}ms`,
|
|
126
|
+
traceCount: this.traces.length,
|
|
127
|
+
traces: this.traces
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Gets trace data and merges it into a response object
|
|
133
|
+
* @param {Object} response - Original response object
|
|
134
|
+
* @returns {Object} Response with debug trace data appended (if debug mode enabled)
|
|
135
|
+
*/
|
|
136
|
+
wrapResponse(response) {
|
|
137
|
+
const traceData = this.getTraceData();
|
|
138
|
+
if (!traceData) {
|
|
139
|
+
return response;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
...response,
|
|
144
|
+
_debug: traceData
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Static helper to create tracer from Express request
|
|
150
|
+
* @param {Object} req - Express request object
|
|
151
|
+
* @returns {DebugTracer} New tracer instance
|
|
152
|
+
*/
|
|
153
|
+
static fromRequest(req) {
|
|
154
|
+
return new DebugTracer(req.headers || {});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { DebugTracer };
|
|
159
|
+
|
package/lib/oauth.js
CHANGED
|
@@ -32,7 +32,7 @@ async function checkAndRefreshAccessToken(oauthApp, user, tokenLockTimeout = 20)
|
|
|
32
32
|
// Other CRMs - check if token will expire within the buffer time
|
|
33
33
|
if (user && user.accessToken && user.refreshToken && tokenExpiry.isBefore(now.clone().add(expiryBuffer, 'minutes'))) {
|
|
34
34
|
// case: use dynamoDB to manage token refresh lock
|
|
35
|
-
if (process.env.USE_TOKEN_REFRESH_LOCK_PLATFORMS
|
|
35
|
+
if (process.env.USE_TOKEN_REFRESH_LOCK_PLATFORMS?.split(',')?.includes(user.platform)) {
|
|
36
36
|
let newLock;
|
|
37
37
|
const { Lock } = require('../models/dynamo/lockSchema');
|
|
38
38
|
// Try to atomically create lock only if it doesn't exist
|
|
@@ -40,7 +40,7 @@ async function checkAndRefreshAccessToken(oauthApp, user, tokenLockTimeout = 20)
|
|
|
40
40
|
newLock = await Lock.create(
|
|
41
41
|
{
|
|
42
42
|
userId: user.id,
|
|
43
|
-
ttl: now.unix() +
|
|
43
|
+
ttl: now.unix() + tokenLockTimeout
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
overwrite: false
|
|
@@ -59,7 +59,7 @@ async function checkAndRefreshAccessToken(oauthApp, user, tokenLockTimeout = 20)
|
|
|
59
59
|
newLock = await Lock.create(
|
|
60
60
|
{
|
|
61
61
|
userId: user.id,
|
|
62
|
-
ttl: now.unix() +
|
|
62
|
+
ttl: now.unix() + tokenLockTimeout
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
overwrite: false
|
|
@@ -97,22 +97,32 @@ async function checkAndRefreshAccessToken(oauthApp, user, tokenLockTimeout = 20)
|
|
|
97
97
|
throw e;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
100
|
+
try {
|
|
101
|
+
const startRefreshTime = moment();
|
|
102
|
+
const token = oauthApp.createToken(user.accessToken, user.refreshToken);
|
|
103
|
+
console.log('token refreshing...')
|
|
104
|
+
const { accessToken, refreshToken, expires } = await token.refresh();
|
|
105
|
+
user.accessToken = accessToken;
|
|
106
|
+
user.refreshToken = refreshToken;
|
|
107
|
+
user.tokenExpiry = expires;
|
|
108
|
+
await user.save();
|
|
109
|
+
if (newLock) {
|
|
110
|
+
const deletionStartTime = moment();
|
|
111
|
+
await newLock.delete();
|
|
112
|
+
const deletionEndTime = moment();
|
|
113
|
+
console.log(`lock deleted in ${deletionEndTime.diff(deletionStartTime)}ms`)
|
|
114
|
+
}
|
|
115
|
+
const endRefreshTime = moment();
|
|
116
|
+
console.log(`token refreshing finished in ${endRefreshTime.diff(startRefreshTime)}ms`)
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
console.log('token refreshing failed', e.stack)
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (newLock) {
|
|
123
|
+
await newLock.delete();
|
|
124
|
+
}
|
|
113
125
|
}
|
|
114
|
-
const endRefreshTime = moment();
|
|
115
|
-
console.log(`token refreshing finished in ${endRefreshTime.diff(startRefreshTime)}ms`)
|
|
116
126
|
}
|
|
117
127
|
// case: run withou token refresh lock
|
|
118
128
|
else {
|
package/lib/ringcentral.js
CHANGED
|
@@ -189,10 +189,10 @@ class RingCentral {
|
|
|
189
189
|
return response.json();
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
async getCallsAggregationData({ token, timezone, timeFrom, timeTo }) {
|
|
192
|
+
async getCallsAggregationData({ token, timezone, timeFrom, timeTo, groupBy }) {
|
|
193
193
|
const body = {
|
|
194
194
|
grouping: {
|
|
195
|
-
groupBy
|
|
195
|
+
groupBy
|
|
196
196
|
},
|
|
197
197
|
timeSettings: {
|
|
198
198
|
timeZone: timezone,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// packages/core/lib/s3ErrorReport.js
|
|
2
|
+
const { S3Client, PutObjectCommand, GetObjectCommand, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3');
|
|
3
|
+
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
|
4
|
+
const shortid = require('shortid');
|
|
5
|
+
|
|
6
|
+
const BUCKET_NAME = process.env.ERROR_REPORT_S3_BUCKET || 'error-reports-bucket';
|
|
7
|
+
const PRESIGN_EXPIRY = 300; // 5 minutes
|
|
8
|
+
const IS_LOCAL = !process.env.ERROR_REPORT_S3_BUCKET;
|
|
9
|
+
const LOCALSTACK_ENDPOINT = process.env.LOCALSTACK_ENDPOINT || 'http://localhost:9001';
|
|
10
|
+
|
|
11
|
+
const s3Client = new S3Client({
|
|
12
|
+
region: process.env.AWS_REGION || 'us-east-1',
|
|
13
|
+
...(IS_LOCAL && {
|
|
14
|
+
endpoint: LOCALSTACK_ENDPOINT,
|
|
15
|
+
forcePathStyle: true, // Required for LocalStack/MinIO
|
|
16
|
+
credentials: {
|
|
17
|
+
accessKeyId: 'minioadmin',
|
|
18
|
+
secretAccessKey: 'minioadmin'
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Ensure bucket exists (useful for local testing)
|
|
25
|
+
*/
|
|
26
|
+
async function ensureBucketExists() {
|
|
27
|
+
if (!IS_LOCAL) return;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
|
|
33
|
+
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
|
|
34
|
+
console.log(`[LocalStack] Created bucket: ${BUCKET_NAME}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate presigned URL for upload
|
|
41
|
+
*/
|
|
42
|
+
async function getUploadUrl({ userId, platform, metadata }) {
|
|
43
|
+
await ensureBucketExists();
|
|
44
|
+
|
|
45
|
+
const reportId = shortid.generate();
|
|
46
|
+
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
|
47
|
+
const key = `error-reports/${timestamp}/${userId}-${reportId}.json`;
|
|
48
|
+
|
|
49
|
+
const command = new PutObjectCommand({
|
|
50
|
+
Bucket: BUCKET_NAME,
|
|
51
|
+
Key: key,
|
|
52
|
+
ContentType: 'application/json',
|
|
53
|
+
Metadata: {
|
|
54
|
+
'user-id': userId,
|
|
55
|
+
'platform': platform,
|
|
56
|
+
...metadata
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: PRESIGN_EXPIRY });
|
|
61
|
+
|
|
62
|
+
return uploadUrl;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
exports.getUploadUrl = getUploadUrl;
|
|
66
|
+
exports.ensureBucketExists = ensureBucketExists;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-connect/core",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.8",
|
|
4
4
|
"description": "RingCentral App Connect Core",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"axios": "^1.12.2",
|
|
18
18
|
"express": "^4.21.2",
|
|
19
|
-
"pg": "^8.8.0",
|
|
20
|
-
"sequelize": "^6.29.0",
|
|
21
19
|
"moment": "^2.29.4",
|
|
22
|
-
"moment-timezone": "^0.5.39"
|
|
20
|
+
"moment-timezone": "^0.5.39",
|
|
21
|
+
"pg": "^8.8.0",
|
|
22
|
+
"sequelize": "^6.29.0"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@aws-sdk/client-dynamodb": "^3.751.0",
|
|
26
|
+
"@aws-sdk/client-s3": "^3.947.0",
|
|
27
|
+
"@aws-sdk/s3-request-presigner": "^3.947.0",
|
|
26
28
|
"body-parser": "^1.20.3",
|
|
27
29
|
"client-oauth2": "^4.3.3",
|
|
28
30
|
"cors": "^2.8.5",
|
|
@@ -45,8 +47,8 @@
|
|
|
45
47
|
"@eslint/js": "^9.22.0",
|
|
46
48
|
"@octokit/rest": "^19.0.5",
|
|
47
49
|
"axios": "^1.12.2",
|
|
48
|
-
"express": "^4.21.2",
|
|
49
50
|
"eslint": "^9.22.0",
|
|
51
|
+
"express": "^4.21.2",
|
|
50
52
|
"globals": "^16.0.0",
|
|
51
53
|
"jest": "^29.3.1",
|
|
52
54
|
"moment": "^2.29.4",
|
package/releaseNotes.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
{
|
|
2
|
+
"1.7.8": {
|
|
3
|
+
"global": [
|
|
4
|
+
{
|
|
5
|
+
"type": "New",
|
|
6
|
+
"description": "Error report feature on support page"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"type": "Better",
|
|
10
|
+
"description": "Calldown list supports better edit feature"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"type": "Better",
|
|
14
|
+
"description": "Connctor selection list can be opened back again"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "Fix",
|
|
18
|
+
"description": "Call log record is shown with a wrong number after the call"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"1.7.7": {
|
|
23
|
+
"global": [
|
|
24
|
+
{
|
|
25
|
+
"type": "New",
|
|
26
|
+
"description": "Admin report company stats now support grouping"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"type": "New",
|
|
30
|
+
"description": "Users can open back the platform selection page from user setting page"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"1.7.6": {
|
|
35
|
+
"global": [
|
|
36
|
+
{
|
|
37
|
+
"type": "Fix",
|
|
38
|
+
"description": "Duplicated contact pop when complete warm transfer"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"type": "Fix",
|
|
42
|
+
"description": "Multi-region auth"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
2
46
|
"1.7.5": {
|
|
3
47
|
"global": [
|
|
4
48
|
{
|