@memberjunction/integration-connectors 5.35.0 → 5.36.0
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/dist/AptifyConnector.d.ts +88 -0
- package/dist/AptifyConnector.d.ts.map +1 -0
- package/dist/AptifyConnector.js +737 -0
- package/dist/AptifyConnector.js.map +1 -0
- package/dist/BettyConnector.d.ts +288 -0
- package/dist/BettyConnector.d.ts.map +1 -0
- package/dist/BettyConnector.js +909 -0
- package/dist/BettyConnector.js.map +1 -0
- package/dist/BlackbaudConnector.d.ts +64 -0
- package/dist/BlackbaudConnector.d.ts.map +1 -0
- package/dist/BlackbaudConnector.js +916 -0
- package/dist/BlackbaudConnector.js.map +1 -0
- package/dist/ConstantContactConnector.d.ts +126 -0
- package/dist/ConstantContactConnector.d.ts.map +1 -0
- package/dist/ConstantContactConnector.js +636 -0
- package/dist/ConstantContactConnector.js.map +1 -0
- package/dist/GrowthZoneConnector.d.ts +185 -0
- package/dist/GrowthZoneConnector.d.ts.map +1 -0
- package/dist/GrowthZoneConnector.js +745 -0
- package/dist/GrowthZoneConnector.js.map +1 -0
- package/dist/HubSpotConnector.d.ts +2 -2
- package/dist/HubSpotConnector.d.ts.map +1 -1
- package/dist/HubSpotConnector.js +3 -3
- package/dist/HubSpotConnector.js.map +1 -1
- package/dist/IMISConnector.d.ts +155 -0
- package/dist/IMISConnector.d.ts.map +1 -0
- package/dist/IMISConnector.js +686 -0
- package/dist/IMISConnector.js.map +1 -0
- package/dist/MJToMJConnector.d.ts +279 -0
- package/dist/MJToMJConnector.d.ts.map +1 -0
- package/dist/MJToMJConnector.js +893 -0
- package/dist/MJToMJConnector.js.map +1 -0
- package/dist/MagnetMailConnector.d.ts +163 -0
- package/dist/MagnetMailConnector.d.ts.map +1 -0
- package/dist/MagnetMailConnector.js +816 -0
- package/dist/MagnetMailConnector.js.map +1 -0
- package/dist/MailchimpConnector.d.ts +188 -0
- package/dist/MailchimpConnector.d.ts.map +1 -0
- package/dist/MailchimpConnector.js +753 -0
- package/dist/MailchimpConnector.js.map +1 -0
- package/dist/NetForumConnector.d.ts +51 -0
- package/dist/NetForumConnector.d.ts.map +1 -0
- package/dist/NetForumConnector.js +419 -0
- package/dist/NetForumConnector.js.map +1 -0
- package/dist/NetSuiteConnector.d.ts +70 -0
- package/dist/NetSuiteConnector.d.ts.map +1 -0
- package/dist/NetSuiteConnector.js +605 -0
- package/dist/NetSuiteConnector.js.map +1 -0
- package/dist/NimbleAMSConnector.d.ts +49 -0
- package/dist/NimbleAMSConnector.d.ts.map +1 -0
- package/dist/NimbleAMSConnector.js +408 -0
- package/dist/NimbleAMSConnector.js.map +1 -0
- package/dist/PropFuelConnector.d.ts +68 -0
- package/dist/PropFuelConnector.d.ts.map +1 -0
- package/dist/PropFuelConnector.js +514 -0
- package/dist/PropFuelConnector.js.map +1 -0
- package/dist/RasaConnector.d.ts +26 -0
- package/dist/RasaConnector.d.ts.map +1 -1
- package/dist/RasaConnector.js +233 -1
- package/dist/RasaConnector.js.map +1 -1
- package/dist/Reach360Connector.d.ts +165 -0
- package/dist/Reach360Connector.d.ts.map +1 -0
- package/dist/Reach360Connector.js +710 -0
- package/dist/Reach360Connector.js.map +1 -0
- package/dist/SharePointConnector.d.ts +181 -0
- package/dist/SharePointConnector.d.ts.map +1 -0
- package/dist/SharePointConnector.js +796 -0
- package/dist/SharePointConnector.js.map +1 -0
- package/dist/WicketConnector.d.ts +1 -0
- package/dist/WicketConnector.d.ts.map +1 -1
- package/dist/WicketConnector.js +6 -0
- package/dist/WicketConnector.js.map +1 -1
- package/dist/WildApricotConnector.d.ts +188 -0
- package/dist/WildApricotConnector.d.ts.map +1 -0
- package/dist/WildApricotConnector.js +1092 -0
- package/dist/WildApricotConnector.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
8
|
+
import { Metadata } from '@memberjunction/core';
|
|
9
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
10
|
+
// ─── Constants ────────────────────────────────────────────────────────
|
|
11
|
+
const REACH360_BASE_URL = 'https://api.reach360.com';
|
|
12
|
+
/** Default max retries when a request fails with a retryable error. */
|
|
13
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
14
|
+
/** Default HTTP request timeout (ms). */
|
|
15
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
16
|
+
/** Minimum interval between outbound requests (ms) — Reach 360 rate limits are undocumented. */
|
|
17
|
+
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 250;
|
|
18
|
+
/** Default page size when the caller does not specify one — Reach 360 uses 50. Max 100. */
|
|
19
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
20
|
+
/** Absolute ceiling for requested page size per the documented API. */
|
|
21
|
+
const MAX_PAGE_SIZE = 100;
|
|
22
|
+
// ─── Static Action Metadata ───────────────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* Objects exposed through the action generator. Mirrors the runtime metadata
|
|
25
|
+
* defined in `metadata/integrations/.reach360.json` but is maintained here so
|
|
26
|
+
* action generation does not require a live connection.
|
|
27
|
+
*/
|
|
28
|
+
const REACH360_OBJECTS = [
|
|
29
|
+
{
|
|
30
|
+
Name: 'Courses', DisplayName: 'Courses',
|
|
31
|
+
Description: 'Courses authored in Articulate Rise/Storyline/Microlearning or imported SCORM/xAPI content.',
|
|
32
|
+
SupportsWrite: false,
|
|
33
|
+
Fields: [
|
|
34
|
+
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Unique identifier of the course' },
|
|
35
|
+
{ Name: 'title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Course title' },
|
|
36
|
+
{ Name: 'contentType', DisplayName: 'Content Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Content source (rise/storyline/microlearning/imported)' },
|
|
37
|
+
{ Name: 'coverImageUrl', DisplayName: 'Cover Image URL', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'URL for course cover image' },
|
|
38
|
+
{ Name: 'authorUrl', DisplayName: 'Author URL', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'HATEOAS URL for course author' },
|
|
39
|
+
{ Name: 'url', DisplayName: 'URL', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Canonical URL for this course' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
Name: 'Users', DisplayName: 'Users',
|
|
44
|
+
Description: 'Reach 360 users — created via invitation or SSO provisioning.',
|
|
45
|
+
SupportsWrite: false,
|
|
46
|
+
Fields: [
|
|
47
|
+
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Unique identifier of the user' },
|
|
48
|
+
{ Name: 'email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User email — natural key' },
|
|
49
|
+
{ Name: 'role', DisplayName: 'Role', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'learner | author | admin' },
|
|
50
|
+
{ Name: 'firstName', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User first name' },
|
|
51
|
+
{ Name: 'lastName', DisplayName: 'Last Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User last name' },
|
|
52
|
+
{ Name: 'lastActiveAt', DisplayName: 'Last Active At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last platform activity timestamp' },
|
|
53
|
+
{ Name: 'articulate360User', DisplayName: 'Articulate 360 User', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Managed by Articulate 360 Teams subscription' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
Name: 'Groups', DisplayName: 'Groups',
|
|
58
|
+
Description: 'Groups used for user tagging/categorization and course assignment. UI-created; API supports membership mutations.',
|
|
59
|
+
SupportsWrite: false,
|
|
60
|
+
Fields: [
|
|
61
|
+
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Unique identifier of the group' },
|
|
62
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Group name' },
|
|
63
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Group description' },
|
|
64
|
+
{ Name: 'usersUrl', DisplayName: 'Users URL', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'HATEOAS URL for group members' },
|
|
65
|
+
{ Name: 'coursesUrl', DisplayName: 'Courses URL', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'HATEOAS URL for group courses' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
Name: 'LearningPaths', DisplayName: 'Learning Paths',
|
|
70
|
+
Description: 'Ordered sequences of courses — created in the UI; enrollments manageable via API.',
|
|
71
|
+
SupportsWrite: false,
|
|
72
|
+
Fields: [
|
|
73
|
+
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Unique identifier of the learning path' },
|
|
74
|
+
{ Name: 'title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Learning path title' },
|
|
75
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Learning path description' },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
Name: 'ActivityReport', DisplayName: 'Activity Report',
|
|
80
|
+
Description: 'Account-wide session activity feed — primary source for incremental engagement data.',
|
|
81
|
+
SupportsWrite: false,
|
|
82
|
+
Fields: [
|
|
83
|
+
{ Name: 'courseId', DisplayName: 'Course ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Course identifier' },
|
|
84
|
+
{ Name: 'userId', DisplayName: 'User ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'User identifier' },
|
|
85
|
+
{ Name: 'status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Session status' },
|
|
86
|
+
{ Name: 'startedAt', DisplayName: 'Started At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Session start timestamp' },
|
|
87
|
+
{ Name: 'completedAt', DisplayName: 'Completed At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Session completion timestamp (watermark field)' },
|
|
88
|
+
{ Name: 'durationSeconds', DisplayName: 'Duration', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Session active duration in seconds' },
|
|
89
|
+
{ Name: 'quizScore', DisplayName: 'Quiz Score', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Quiz score when applicable' },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* Maps lowercase object names to the top-level response key that holds the
|
|
95
|
+
* record array for list endpoints. Reach 360 wraps every list response under
|
|
96
|
+
* a plural key (`courses`, `users`, `sessions`, etc.) alongside `nextUrl`.
|
|
97
|
+
*/
|
|
98
|
+
const REACH360_RESPONSE_DATA_KEYS = {
|
|
99
|
+
courses: 'courses',
|
|
100
|
+
users: 'users',
|
|
101
|
+
groups: 'groups',
|
|
102
|
+
learningpaths: 'learningPaths',
|
|
103
|
+
activityreport: 'sessions',
|
|
104
|
+
};
|
|
105
|
+
/** Maps lowercase object names to their list API paths (relative to base URL). */
|
|
106
|
+
const REACH360_API_PATHS = {
|
|
107
|
+
courses: '/courses',
|
|
108
|
+
users: '/users',
|
|
109
|
+
groups: '/groups',
|
|
110
|
+
learningpaths: '/learning-paths',
|
|
111
|
+
activityreport: '/reports/activity',
|
|
112
|
+
};
|
|
113
|
+
// ─── Connector Implementation ─────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* Connector for the Reach 360 LMS REST API.
|
|
116
|
+
*
|
|
117
|
+
* Reach 360 is Articulate's lightweight cloud LMS. The API is documented only
|
|
118
|
+
* as HTML articles (no OpenAPI spec) and exposes ~25 endpoints across Courses,
|
|
119
|
+
* Users, Groups, Learning Paths, Reports, Invitations and Completions.
|
|
120
|
+
*
|
|
121
|
+
* Auth: a single static Bearer token generated in the Reach 360 admin UI. No
|
|
122
|
+
* OAuth flow, no refresh tokens. The token is sent on every request as
|
|
123
|
+
* `Authorization: Bearer {apiKey}`.
|
|
124
|
+
*
|
|
125
|
+
* Pagination: cursor-based. List responses include a `nextUrl` pointer that
|
|
126
|
+
* encodes the next cursor; follow until null.
|
|
127
|
+
*
|
|
128
|
+
* Incremental sync: Reach 360 has no CDC endpoint and no server-side date
|
|
129
|
+
* filtering on list endpoints. Instead, `/reports/activity` acts as a
|
|
130
|
+
* session-level change stream; callers filter client-side by `completedAt`.
|
|
131
|
+
*/
|
|
132
|
+
let Reach360Connector = class Reach360Connector extends BaseRESTIntegrationConnector {
|
|
133
|
+
constructor() {
|
|
134
|
+
super(...arguments);
|
|
135
|
+
/** Timestamp of the last HTTP request — used for throttling. */
|
|
136
|
+
this.lastRequestTime = 0;
|
|
137
|
+
}
|
|
138
|
+
// ── Capability getters ────────────────────────────────────────────
|
|
139
|
+
get SupportsCreate() { return true; } // Invitations, Completions
|
|
140
|
+
get SupportsUpdate() { return true; } // Groups update via PUT /groups/{groupId}; other primary resources still return 400 in UpdateRecord
|
|
141
|
+
get SupportsDelete() { return true; } // User delete, group membership removal
|
|
142
|
+
get IntegrationName() { return 'Reach360'; }
|
|
143
|
+
// ── Action generation ─────────────────────────────────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Returns the static object catalog for action generation. Derived from
|
|
146
|
+
* Articulate's documented endpoint catalog (no OpenAPI spec exists).
|
|
147
|
+
*/
|
|
148
|
+
GetIntegrationObjects() {
|
|
149
|
+
return REACH360_OBJECTS;
|
|
150
|
+
}
|
|
151
|
+
GetActionGeneratorConfig() {
|
|
152
|
+
const objects = this.GetIntegrationObjects();
|
|
153
|
+
if (objects.length === 0)
|
|
154
|
+
return null;
|
|
155
|
+
return {
|
|
156
|
+
IntegrationName: 'Reach360',
|
|
157
|
+
CategoryName: 'Reach360',
|
|
158
|
+
IconClass: 'fa-solid fa-graduation-cap',
|
|
159
|
+
Objects: objects,
|
|
160
|
+
IncludeSearch: false,
|
|
161
|
+
IncludeList: false,
|
|
162
|
+
CategoryDescription: 'Reach 360 LMS integration actions',
|
|
163
|
+
ParentCategoryName: 'Learning',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
GetDefaultConfiguration() {
|
|
167
|
+
return {
|
|
168
|
+
DefaultSchemaName: 'Reach360',
|
|
169
|
+
DefaultObjects: [
|
|
170
|
+
{
|
|
171
|
+
SourceObjectName: 'Users',
|
|
172
|
+
TargetTableName: 'Reach360_User',
|
|
173
|
+
TargetEntityName: 'Reach360 Users',
|
|
174
|
+
SyncEnabled: true,
|
|
175
|
+
FieldMappings: this.GetDefaultFieldMappings('Users', 'Contacts'),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
SourceObjectName: 'Courses',
|
|
179
|
+
TargetTableName: 'Reach360_Course',
|
|
180
|
+
TargetEntityName: 'Reach360 Courses',
|
|
181
|
+
SyncEnabled: true,
|
|
182
|
+
FieldMappings: this.GetDefaultFieldMappings('Courses', 'Courses'),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
SourceObjectName: 'ActivityReport',
|
|
186
|
+
TargetTableName: 'Reach360_Activity',
|
|
187
|
+
TargetEntityName: 'Reach360 Activity',
|
|
188
|
+
SyncEnabled: true,
|
|
189
|
+
FieldMappings: [],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
GetDefaultFieldMappings(objectName, _entityName) {
|
|
195
|
+
switch (objectName) {
|
|
196
|
+
case 'Users':
|
|
197
|
+
return [
|
|
198
|
+
{ SourceFieldName: 'id', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
199
|
+
{ SourceFieldName: 'email', DestinationFieldName: 'Email' },
|
|
200
|
+
{ SourceFieldName: 'firstName', DestinationFieldName: 'FirstName' },
|
|
201
|
+
{ SourceFieldName: 'lastName', DestinationFieldName: 'LastName' },
|
|
202
|
+
];
|
|
203
|
+
case 'Courses':
|
|
204
|
+
return [
|
|
205
|
+
{ SourceFieldName: 'id', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
206
|
+
{ SourceFieldName: 'title', DestinationFieldName: 'Title' },
|
|
207
|
+
];
|
|
208
|
+
default:
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// ─── TestConnection ──────────────────────────────────────────────
|
|
213
|
+
/**
|
|
214
|
+
* Verifies connectivity by hitting GET /users?limit=1. A 2xx response with
|
|
215
|
+
* a recognizable envelope confirms both credentials and rate-limit posture.
|
|
216
|
+
*/
|
|
217
|
+
async TestConnection(companyIntegration, contextUser) {
|
|
218
|
+
try {
|
|
219
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
220
|
+
const url = `${REACH360_BASE_URL}/users?limit=1`;
|
|
221
|
+
const headers = this.BuildHeaders(auth);
|
|
222
|
+
const resp = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
223
|
+
if (resp.Status < 200 || resp.Status >= 300) {
|
|
224
|
+
return { Success: false, Message: `Reach360 TestConnection failed: HTTP ${resp.Status}` };
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
Success: true,
|
|
228
|
+
Message: 'Successfully connected to Reach 360',
|
|
229
|
+
ServerVersion: 'Reach 360 REST API v1',
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
234
|
+
return { Success: false, Message: `Connection failed: ${message}` };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// ─── Discovery ────────────────────────────────────────────────────
|
|
238
|
+
/**
|
|
239
|
+
* Overrides the base cache-driven discovery so the connector is usable
|
|
240
|
+
* before the integration metadata has been seeded into the DB (e.g. at
|
|
241
|
+
* first setup). Returns the static REACH360_OBJECTS catalog.
|
|
242
|
+
*/
|
|
243
|
+
async DiscoverObjects(_companyIntegration, _contextUser) {
|
|
244
|
+
return REACH360_OBJECTS.map(obj => ({
|
|
245
|
+
Name: obj.Name,
|
|
246
|
+
Label: obj.DisplayName,
|
|
247
|
+
Description: obj.Description,
|
|
248
|
+
SupportsIncrementalSync: obj.Name === 'ActivityReport' || obj.Name === 'Users',
|
|
249
|
+
SupportsWrite: obj.SupportsWrite,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Returns the static field list for the named object. Reach 360 has no
|
|
254
|
+
* custom-field system, so there is no runtime enrichment to perform.
|
|
255
|
+
*/
|
|
256
|
+
async DiscoverFields(_companyIntegration, objectName, _contextUser) {
|
|
257
|
+
const obj = REACH360_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
|
|
258
|
+
if (!obj)
|
|
259
|
+
return [];
|
|
260
|
+
return obj.Fields.map(f => ({
|
|
261
|
+
Name: f.Name,
|
|
262
|
+
Label: f.DisplayName,
|
|
263
|
+
Description: f.Description,
|
|
264
|
+
DataType: f.Type,
|
|
265
|
+
IsRequired: f.IsRequired,
|
|
266
|
+
IsUniqueKey: f.IsPrimaryKey,
|
|
267
|
+
IsReadOnly: f.IsReadOnly,
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
// ─── FetchChanges (cursor pagination) ────────────────────────────
|
|
271
|
+
/**
|
|
272
|
+
* Reach 360 uses cursor pagination via `nextUrl`. Fetches the configured
|
|
273
|
+
* page size, follows the cursor chain until either `nextUrl` is null or
|
|
274
|
+
* `BatchSize` is reached.
|
|
275
|
+
*
|
|
276
|
+
* For `ActivityReport` and `Users` objects the caller's watermark is
|
|
277
|
+
* applied client-side against `completedAt` / `lastActiveAt` respectively.
|
|
278
|
+
*/
|
|
279
|
+
async FetchChanges(ctx) {
|
|
280
|
+
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
281
|
+
const objLower = ctx.ObjectName.toLowerCase();
|
|
282
|
+
const path = REACH360_API_PATHS[objLower];
|
|
283
|
+
if (!path) {
|
|
284
|
+
console.warn(`[Reach360] No API path mapped for object "${ctx.ObjectName}"`);
|
|
285
|
+
return { Records: [], HasMore: false };
|
|
286
|
+
}
|
|
287
|
+
const dataKey = REACH360_RESPONSE_DATA_KEYS[objLower] ?? objLower;
|
|
288
|
+
const pageSize = Math.min(ctx.BatchSize ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
|
|
289
|
+
const initialUrl = ctx.CurrentCursor
|
|
290
|
+
? this.DecodeCursor(ctx.CurrentCursor)
|
|
291
|
+
: `${REACH360_BASE_URL}${path}?limit=${pageSize}`;
|
|
292
|
+
const { records, nextCursor } = await this.fetchCursorBatch(auth, initialUrl, dataKey, ctx.BatchSize);
|
|
293
|
+
const { filtered, newWatermark } = this.applyClientWatermark(records, objLower, ctx.WatermarkValue);
|
|
294
|
+
const externalRecords = filtered.map(r => this.toExternalRecord(r, ctx.ObjectName));
|
|
295
|
+
return {
|
|
296
|
+
Records: externalRecords,
|
|
297
|
+
HasMore: nextCursor != null,
|
|
298
|
+
NextCursor: nextCursor ?? undefined,
|
|
299
|
+
NewWatermarkValue: !nextCursor && newWatermark ? newWatermark : undefined,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Walks the `nextUrl` chain starting from `startUrl` and accumulates up to
|
|
304
|
+
* `batchSize` records. Returns the raw record array and the final cursor
|
|
305
|
+
* (base64-encoded `nextUrl` when more remain, or null).
|
|
306
|
+
*/
|
|
307
|
+
async fetchCursorBatch(auth, startUrl, dataKey, batchSize) {
|
|
308
|
+
const all = [];
|
|
309
|
+
let url = startUrl;
|
|
310
|
+
const limit = batchSize > 0 ? batchSize : Number.MAX_SAFE_INTEGER;
|
|
311
|
+
while (url && all.length < limit) {
|
|
312
|
+
const headers = this.BuildHeaders(auth);
|
|
313
|
+
const resp = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
314
|
+
if (resp.Status < 200 || resp.Status >= 300) {
|
|
315
|
+
throw new Error(`Reach360 GET ${url} returned HTTP ${resp.Status}`);
|
|
316
|
+
}
|
|
317
|
+
const body = (resp.Body ?? {});
|
|
318
|
+
const arr = body[dataKey];
|
|
319
|
+
if (Array.isArray(arr)) {
|
|
320
|
+
all.push(...arr);
|
|
321
|
+
}
|
|
322
|
+
const next = typeof body.nextUrl === 'string' && body.nextUrl.length > 0 ? body.nextUrl : null;
|
|
323
|
+
if (!next || all.length >= limit) {
|
|
324
|
+
return { records: all.slice(0, limit), nextCursor: all.length >= limit && next ? this.EncodeCursor(next) : null };
|
|
325
|
+
}
|
|
326
|
+
url = next;
|
|
327
|
+
}
|
|
328
|
+
return { records: all, nextCursor: null };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Applies a client-side watermark filter against the appropriate date
|
|
332
|
+
* field for the given object. Returns records newer than the watermark
|
|
333
|
+
* plus the latest observed timestamp (to update the watermark after the
|
|
334
|
+
* final page).
|
|
335
|
+
*/
|
|
336
|
+
applyClientWatermark(records, objLower, watermark) {
|
|
337
|
+
const dateField = this.watermarkFieldFor(objLower);
|
|
338
|
+
if (!dateField)
|
|
339
|
+
return { filtered: records, newWatermark: null };
|
|
340
|
+
const watermarkDate = watermark ? new Date(watermark) : null;
|
|
341
|
+
let latest = null;
|
|
342
|
+
const filtered = [];
|
|
343
|
+
for (const rec of records) {
|
|
344
|
+
const raw = rec[dateField];
|
|
345
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
346
|
+
filtered.push(rec);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const parsed = new Date(raw);
|
|
350
|
+
if (isNaN(parsed.getTime())) {
|
|
351
|
+
filtered.push(rec);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!latest || parsed > latest)
|
|
355
|
+
latest = parsed;
|
|
356
|
+
if (!watermarkDate || parsed > watermarkDate)
|
|
357
|
+
filtered.push(rec);
|
|
358
|
+
}
|
|
359
|
+
return { filtered, newWatermark: latest ? latest.toISOString() : null };
|
|
360
|
+
}
|
|
361
|
+
/** Returns the date field used for incremental filtering, if any. */
|
|
362
|
+
watermarkFieldFor(objLower) {
|
|
363
|
+
if (objLower === 'activityreport')
|
|
364
|
+
return 'completedAt';
|
|
365
|
+
if (objLower === 'users')
|
|
366
|
+
return 'lastActiveAt';
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Converts a raw Reach 360 record into an ExternalRecord. ActivityReport
|
|
371
|
+
* uses a composite {courseId, userId} key.
|
|
372
|
+
*/
|
|
373
|
+
toExternalRecord(raw, objectType) {
|
|
374
|
+
const externalID = this.extractExternalID(raw, objectType);
|
|
375
|
+
return { ExternalID: externalID, ObjectType: objectType, Fields: raw };
|
|
376
|
+
}
|
|
377
|
+
extractExternalID(raw, objectType) {
|
|
378
|
+
if (objectType.toLowerCase() === 'activityreport') {
|
|
379
|
+
const cid = raw['courseId'] != null ? String(raw['courseId']) : '';
|
|
380
|
+
const uid = raw['userId'] != null ? String(raw['userId']) : '';
|
|
381
|
+
return `${cid}|${uid}`;
|
|
382
|
+
}
|
|
383
|
+
return raw['id'] != null ? String(raw['id']) : '';
|
|
384
|
+
}
|
|
385
|
+
// ─── CRUD Operations ─────────────────────────────────────────────
|
|
386
|
+
/**
|
|
387
|
+
* Supported creates:
|
|
388
|
+
* - Invitations → POST /invitations
|
|
389
|
+
* - Completions → POST /courses/{courseId}/users/{userId}/completions
|
|
390
|
+
* (requires `courseId` + `userId` on Attributes)
|
|
391
|
+
* - Group membership → POST /groups/{groupId}/users
|
|
392
|
+
* (requires `groupId` on Attributes)
|
|
393
|
+
*/
|
|
394
|
+
async CreateRecord(ctx) {
|
|
395
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
396
|
+
const contextUser = ctx.ContextUser;
|
|
397
|
+
try {
|
|
398
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
399
|
+
const headers = this.BuildHeaders(auth);
|
|
400
|
+
const route = this.resolveCreateRoute(ctx.ObjectName, ctx.Attributes);
|
|
401
|
+
if (!route) {
|
|
402
|
+
return { Success: false, ErrorMessage: `Reach360 CreateRecord not supported for "${ctx.ObjectName}"`, StatusCode: 400 };
|
|
403
|
+
}
|
|
404
|
+
const url = `${REACH360_BASE_URL}${route.path}`;
|
|
405
|
+
const resp = await this.MakeHTTPRequest(auth, url, 'POST', headers, route.body);
|
|
406
|
+
if (resp.Status < 200 || resp.Status >= 300) {
|
|
407
|
+
return this.buildCRUDError(resp, 'CreateRecord', ctx.ObjectName);
|
|
408
|
+
}
|
|
409
|
+
const body = (resp.Body ?? {});
|
|
410
|
+
const id = body['id'] != null ? String(body['id']) : (route.fallbackID ?? '');
|
|
411
|
+
return { Success: true, ExternalID: id, StatusCode: resp.Status };
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
415
|
+
return { Success: false, ErrorMessage: `Reach360 CreateRecord failed: ${message}`, StatusCode: 500 };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Resolves which REST route a CreateRecord call maps to. Drives the router
|
|
420
|
+
* from object name + payload fields so no hardcoded switch on individual
|
|
421
|
+
* object behavior leaks into the HTTP code.
|
|
422
|
+
*/
|
|
423
|
+
resolveCreateRoute(objectName, attrs) {
|
|
424
|
+
const obj = objectName.toLowerCase();
|
|
425
|
+
if (obj === 'invitations') {
|
|
426
|
+
return { path: '/invitations', body: attrs };
|
|
427
|
+
}
|
|
428
|
+
if (obj === 'completions') {
|
|
429
|
+
const courseId = attrs['courseId'];
|
|
430
|
+
const userId = attrs['userId'];
|
|
431
|
+
if (typeof courseId !== 'string' || typeof userId !== 'string')
|
|
432
|
+
return null;
|
|
433
|
+
const { courseId: _c, userId: _u, ...body } = attrs;
|
|
434
|
+
return {
|
|
435
|
+
path: `/courses/${encodeURIComponent(courseId)}/users/${encodeURIComponent(userId)}/completions`,
|
|
436
|
+
body,
|
|
437
|
+
fallbackID: `${courseId}|${userId}`,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (obj === 'groupmembers' || obj === 'groupmembership' || obj === 'groupusers') {
|
|
441
|
+
const groupId = attrs['groupId'];
|
|
442
|
+
if (typeof groupId !== 'string')
|
|
443
|
+
return null;
|
|
444
|
+
const { groupId: _g, ...body } = attrs;
|
|
445
|
+
return { path: `/groups/${encodeURIComponent(groupId)}/users`, body };
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Reach 360 update endpoints (per vendor docs as of audit):
|
|
451
|
+
* - Groups → PUT /groups/{groupId} — name, description, tag updates
|
|
452
|
+
* - Users → not supported (no PUT/PATCH on /users/{id})
|
|
453
|
+
* - Courses → not supported (no PUT/PATCH on /courses/{id})
|
|
454
|
+
* - Learning Paths → not supported
|
|
455
|
+
* - Reports → not supported
|
|
456
|
+
*/
|
|
457
|
+
async UpdateRecord(ctx) {
|
|
458
|
+
const objectLower = ctx.ObjectName.toLowerCase();
|
|
459
|
+
if (objectLower === 'groups') {
|
|
460
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
461
|
+
const contextUser = ctx.ContextUser;
|
|
462
|
+
try {
|
|
463
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
464
|
+
const headers = this.BuildHeaders(auth);
|
|
465
|
+
const url = `${REACH360_BASE_URL}/groups/${encodeURIComponent(ctx.ExternalID)}`;
|
|
466
|
+
const resp = await this.MakeHTTPRequest(auth, url, 'PUT', headers, ctx.Attributes);
|
|
467
|
+
if (resp.Status >= 200 && resp.Status < 300) {
|
|
468
|
+
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: resp.Status };
|
|
469
|
+
}
|
|
470
|
+
return this.buildCRUDError(resp, 'UpdateRecord', ctx.ObjectName);
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
474
|
+
return { Success: false, ErrorMessage: `Reach360 UpdateRecord failed: ${message}`, StatusCode: 500 };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
Success: false,
|
|
479
|
+
ErrorMessage: `Reach360 UpdateRecord not supported for "${ctx.ObjectName}" — only Groups supports PUT in the vendor API.`,
|
|
480
|
+
StatusCode: 400,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Supported deletes:
|
|
485
|
+
* - Users → DELETE /users/{userId}
|
|
486
|
+
* - Group membership → DELETE /groups/{groupId}/users/{userId}
|
|
487
|
+
* (requires `groupId` to be supplied via ExternalID
|
|
488
|
+
* as `{groupId}|{userId}`)
|
|
489
|
+
*/
|
|
490
|
+
async DeleteRecord(ctx) {
|
|
491
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
492
|
+
const contextUser = ctx.ContextUser;
|
|
493
|
+
try {
|
|
494
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
495
|
+
const headers = this.BuildHeaders(auth);
|
|
496
|
+
const path = this.resolveDeletePath(ctx.ObjectName, ctx.ExternalID);
|
|
497
|
+
if (!path) {
|
|
498
|
+
return { Success: false, ErrorMessage: `Reach360 DeleteRecord not supported for "${ctx.ObjectName}"`, StatusCode: 400 };
|
|
499
|
+
}
|
|
500
|
+
const url = `${REACH360_BASE_URL}${path}`;
|
|
501
|
+
const resp = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
|
|
502
|
+
if (resp.Status < 200 || resp.Status >= 300) {
|
|
503
|
+
return this.buildCRUDError(resp, 'DeleteRecord', ctx.ObjectName);
|
|
504
|
+
}
|
|
505
|
+
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: resp.Status };
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
509
|
+
return { Success: false, ErrorMessage: `Reach360 DeleteRecord failed: ${message}`, StatusCode: 500 };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
resolveDeletePath(objectName, externalID) {
|
|
513
|
+
const obj = objectName.toLowerCase();
|
|
514
|
+
if (obj === 'users') {
|
|
515
|
+
return `/users/${encodeURIComponent(externalID)}`;
|
|
516
|
+
}
|
|
517
|
+
if (obj === 'groupmembers' || obj === 'groupmembership' || obj === 'groupusers') {
|
|
518
|
+
const [groupId, userId] = externalID.split('|');
|
|
519
|
+
if (!groupId || !userId)
|
|
520
|
+
return null;
|
|
521
|
+
return `/groups/${encodeURIComponent(groupId)}/users/${encodeURIComponent(userId)}`;
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
buildCRUDError(resp, op, objectName) {
|
|
526
|
+
const body = resp.Body;
|
|
527
|
+
const rawMessage = typeof body?.['message'] === 'string' ? body['message'] : undefined;
|
|
528
|
+
return {
|
|
529
|
+
Success: false,
|
|
530
|
+
ErrorMessage: rawMessage ?? `Reach360 ${op} on ${objectName} failed (HTTP ${resp.Status})`,
|
|
531
|
+
StatusCode: resp.Status,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
// ─── Abstract REST hooks (BaseRESTIntegrationConnector) ───────────
|
|
535
|
+
async Authenticate(companyIntegration, contextUser) {
|
|
536
|
+
const config = await this.parseConfig(companyIntegration, contextUser);
|
|
537
|
+
return { Token: config.apiKey, Config: config };
|
|
538
|
+
}
|
|
539
|
+
BuildHeaders(auth) {
|
|
540
|
+
const token = auth.Token ?? '';
|
|
541
|
+
return {
|
|
542
|
+
'Authorization': `Bearer ${token}`,
|
|
543
|
+
'Accept': 'application/json',
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Not used by this connector — FetchChanges walks `nextUrl` directly and
|
|
548
|
+
* bypasses the base pagination engine. Still required to satisfy the
|
|
549
|
+
* abstract contract.
|
|
550
|
+
*/
|
|
551
|
+
NormalizeResponse(rawBody, responseDataKey) {
|
|
552
|
+
if (!responseDataKey)
|
|
553
|
+
return Array.isArray(rawBody) ? rawBody : [];
|
|
554
|
+
const body = rawBody;
|
|
555
|
+
const arr = body[responseDataKey];
|
|
556
|
+
return Array.isArray(arr) ? arr : [];
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Pagination info is expressed through Reach 360's `nextUrl` pointer which
|
|
560
|
+
* the cursor path already consumes directly. This hook is retained for API
|
|
561
|
+
* compliance and simply reports "no more pages" to anyone calling through
|
|
562
|
+
* the base pipeline.
|
|
563
|
+
*/
|
|
564
|
+
ExtractPaginationInfo(rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
|
|
565
|
+
const body = rawBody;
|
|
566
|
+
const next = typeof body?.nextUrl === 'string' && body.nextUrl.length > 0 ? body.nextUrl : null;
|
|
567
|
+
return { HasMore: next != null, NextCursor: next ?? undefined };
|
|
568
|
+
}
|
|
569
|
+
GetBaseURL(_companyIntegration, _auth) {
|
|
570
|
+
return REACH360_BASE_URL;
|
|
571
|
+
}
|
|
572
|
+
// ─── HTTP transport with retry + throttling ───────────────────────
|
|
573
|
+
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
574
|
+
const r360 = auth;
|
|
575
|
+
const cfg = r360.Config;
|
|
576
|
+
const maxRetries = cfg.MaxRetries ?? DEFAULT_MAX_RETRIES;
|
|
577
|
+
const timeoutMs = cfg.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
578
|
+
const minInterval = cfg.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
|
|
579
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
580
|
+
await this.throttle(minInterval);
|
|
581
|
+
try {
|
|
582
|
+
const resp = await this.doFetch(url, method, headers, body, timeoutMs);
|
|
583
|
+
this.lastRequestTime = Date.now();
|
|
584
|
+
if (resp.Status === 429 || resp.Status === 503) {
|
|
585
|
+
if (attempt === maxRetries)
|
|
586
|
+
return resp;
|
|
587
|
+
await this.sleep(this.backoffMs(attempt));
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
return resp;
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
if (attempt === maxRetries)
|
|
594
|
+
throw err;
|
|
595
|
+
const retryable = this.isRetryableError(err);
|
|
596
|
+
if (!retryable)
|
|
597
|
+
throw err;
|
|
598
|
+
await this.sleep(this.backoffMs(attempt));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
throw new Error(`Reach360 request to ${url} exhausted ${maxRetries + 1} attempts`);
|
|
602
|
+
}
|
|
603
|
+
/** Executes a single fetch() with an AbortController-backed timeout. */
|
|
604
|
+
async doFetch(url, method, headers, body, timeoutMs) {
|
|
605
|
+
const controller = new AbortController();
|
|
606
|
+
const handle = setTimeout(() => controller.abort(), timeoutMs);
|
|
607
|
+
const finalHeaders = { ...headers };
|
|
608
|
+
if (body !== undefined && !finalHeaders['Content-Type']) {
|
|
609
|
+
finalHeaders['Content-Type'] = 'application/json';
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const resp = await fetch(url, {
|
|
613
|
+
method,
|
|
614
|
+
headers: finalHeaders,
|
|
615
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
616
|
+
signal: controller.signal,
|
|
617
|
+
});
|
|
618
|
+
const respHeaders = {};
|
|
619
|
+
resp.headers.forEach((value, key) => { respHeaders[key.toLowerCase()] = value; });
|
|
620
|
+
const text = await resp.text();
|
|
621
|
+
const parsed = text.length > 0 ? this.safeParseJSON(text) : null;
|
|
622
|
+
return { Status: resp.status, Body: parsed, Headers: respHeaders };
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
clearTimeout(handle);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
safeParseJSON(text) {
|
|
629
|
+
try {
|
|
630
|
+
return JSON.parse(text);
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return text;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
isRetryableError(err) {
|
|
637
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
638
|
+
return /abort|timeout|ECONNRESET|ENOTFOUND|ETIMEDOUT|network/i.test(msg);
|
|
639
|
+
}
|
|
640
|
+
backoffMs(attempt) {
|
|
641
|
+
const base = Math.min(1000 * Math.pow(2, attempt), 15000);
|
|
642
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
643
|
+
return base + jitter;
|
|
644
|
+
}
|
|
645
|
+
async throttle(minIntervalMs) {
|
|
646
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
647
|
+
if (elapsed < minIntervalMs) {
|
|
648
|
+
await this.sleep(minIntervalMs - elapsed);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
sleep(ms) {
|
|
652
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
653
|
+
}
|
|
654
|
+
// ─── Cursor helpers ──────────────────────────────────────────────
|
|
655
|
+
/** Encodes a full `nextUrl` into an opaque cursor string. */
|
|
656
|
+
EncodeCursor(nextUrl) {
|
|
657
|
+
return Buffer.from(nextUrl, 'utf-8').toString('base64url');
|
|
658
|
+
}
|
|
659
|
+
/** Decodes a previously encoded cursor back to its `nextUrl`. */
|
|
660
|
+
DecodeCursor(cursor) {
|
|
661
|
+
return Buffer.from(cursor, 'base64url').toString('utf-8');
|
|
662
|
+
}
|
|
663
|
+
// ─── Config parsing ───────────────────────────────────────────────
|
|
664
|
+
async parseConfig(companyIntegration, contextUser) {
|
|
665
|
+
const credentialID = companyIntegration.CredentialID;
|
|
666
|
+
if (credentialID) {
|
|
667
|
+
const fromCred = await this.loadFromCredential(credentialID, contextUser);
|
|
668
|
+
if (fromCred)
|
|
669
|
+
return fromCred;
|
|
670
|
+
}
|
|
671
|
+
const raw = companyIntegration.Configuration;
|
|
672
|
+
if (!raw) {
|
|
673
|
+
throw new Error('Reach360Connector: No credential or Configuration JSON found on CompanyIntegration');
|
|
674
|
+
}
|
|
675
|
+
const parsed = JSON.parse(raw);
|
|
676
|
+
return this.validateConfig(parsed);
|
|
677
|
+
}
|
|
678
|
+
async loadFromCredential(credentialID, contextUser, provider) {
|
|
679
|
+
const md = provider ?? new Metadata();
|
|
680
|
+
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
681
|
+
const loaded = await credential.Load(credentialID);
|
|
682
|
+
if (!loaded || !credential.Values)
|
|
683
|
+
return null;
|
|
684
|
+
try {
|
|
685
|
+
const parsed = JSON.parse(credential.Values);
|
|
686
|
+
return this.validateConfig(parsed);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
validateConfig(raw) {
|
|
693
|
+
if (!raw.apiKey || typeof raw.apiKey !== 'string') {
|
|
694
|
+
throw new Error('Reach360Connector: apiKey is required');
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
apiKey: raw.apiKey,
|
|
698
|
+
MaxRetries: raw.MaxRetries ?? DEFAULT_MAX_RETRIES,
|
|
699
|
+
RequestTimeoutMs: raw.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
700
|
+
MinRequestIntervalMs: raw.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
Reach360Connector = __decorate([
|
|
705
|
+
RegisterClass(BaseIntegrationConnector, 'Reach360Connector')
|
|
706
|
+
], Reach360Connector);
|
|
707
|
+
export { Reach360Connector };
|
|
708
|
+
/** Tree-shaking prevention function — import and call from module entry point. */
|
|
709
|
+
export function LoadReach360Connector() { }
|
|
710
|
+
//# sourceMappingURL=Reach360Connector.js.map
|