@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,686 @@
|
|
|
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 DEFAULT_MAX_RETRIES = 3;
|
|
12
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
13
|
+
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 200;
|
|
14
|
+
const TOKEN_REFRESH_BUFFER_MS = 60_000;
|
|
15
|
+
/** iMIS-to-SQL type mapping used when merging live-discovered field metadata. */
|
|
16
|
+
const IMIS_TYPE_MAP = {
|
|
17
|
+
'string': 'string',
|
|
18
|
+
'datetime': 'datetime',
|
|
19
|
+
'date': 'date',
|
|
20
|
+
'boolean': 'boolean',
|
|
21
|
+
'int32': 'integer',
|
|
22
|
+
'int64': 'integer',
|
|
23
|
+
'integer': 'integer',
|
|
24
|
+
'long': 'integer',
|
|
25
|
+
'decimal': 'decimal',
|
|
26
|
+
'double': 'decimal',
|
|
27
|
+
'float': 'decimal',
|
|
28
|
+
'guid': 'uuid',
|
|
29
|
+
'uuid': 'uuid',
|
|
30
|
+
'monetaryamountdata': 'decimal',
|
|
31
|
+
};
|
|
32
|
+
// ─── Connector ─────────────────────────────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Connector for iMIS EMS (Advanced Solutions International) REST API.
|
|
35
|
+
*
|
|
36
|
+
* Extends BaseRESTIntegrationConnector so the generic metadata-driven fetch
|
|
37
|
+
* loop handles pagination, template variables, and record normalization.
|
|
38
|
+
* Adds iMIS-specific:
|
|
39
|
+
* - OAuth 2.0 Password Grant / SSO refresh-token authentication
|
|
40
|
+
* - /{entity}/metadata runtime field discovery (merged with static IntegrationObjectField metadata, IsCustom=true)
|
|
41
|
+
* - /BOEntityDefinition runtime object discovery (customer Business Objects, IsCustom=true)
|
|
42
|
+
* - PagedResult envelope with Offset/Limit + HasNext flag
|
|
43
|
+
* - UpdatedOn=gt:<iso-date> watermark filter for incremental sync
|
|
44
|
+
* - PUT (full replace) for updates with automatic read-modify-write merge
|
|
45
|
+
*
|
|
46
|
+
* Reference: https://developer.imis.com/docs/imis-rest-api-data-models-and-swagger-json-files
|
|
47
|
+
*/
|
|
48
|
+
let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
49
|
+
constructor() {
|
|
50
|
+
super(...arguments);
|
|
51
|
+
this.authState = null;
|
|
52
|
+
this.lastRequestTime = 0;
|
|
53
|
+
/** Current watermark for the in-flight FetchChanges — used by AppendDefaultQueryParams */
|
|
54
|
+
this.currentWatermark = null;
|
|
55
|
+
}
|
|
56
|
+
// ── Capability flags ────────────────────────────────────────────
|
|
57
|
+
get SupportsCreate() { return true; }
|
|
58
|
+
get SupportsUpdate() { return true; }
|
|
59
|
+
get SupportsDelete() { return true; }
|
|
60
|
+
get SupportsSearch() { return false; }
|
|
61
|
+
get SupportsListing() { return false; }
|
|
62
|
+
get IntegrationName() { return 'iMIS'; }
|
|
63
|
+
// ── Auth + transport (abstract methods from BaseRESTIntegrationConnector) ────
|
|
64
|
+
async Authenticate(companyIntegration, contextUser) {
|
|
65
|
+
return this.GetAuth(companyIntegration, contextUser);
|
|
66
|
+
}
|
|
67
|
+
BuildHeaders(auth) {
|
|
68
|
+
const imisAuth = auth;
|
|
69
|
+
return {
|
|
70
|
+
'Authorization': `Bearer ${imisAuth.Token}`,
|
|
71
|
+
'Accept': 'application/json',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
75
|
+
const imisAuth = auth;
|
|
76
|
+
const maxRetries = imisAuth.Config.MaxRetries ?? DEFAULT_MAX_RETRIES;
|
|
77
|
+
const timeoutMs = imisAuth.Config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
78
|
+
const minInterval = imisAuth.Config.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
|
|
79
|
+
const effectiveHeaders = { ...headers };
|
|
80
|
+
if (body !== undefined && !effectiveHeaders['Content-Type']) {
|
|
81
|
+
effectiveHeaders['Content-Type'] = 'application/json';
|
|
82
|
+
}
|
|
83
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
84
|
+
await this.Throttle(minInterval);
|
|
85
|
+
const response = await this.ExecuteFetch(url, method, effectiveHeaders, body, timeoutMs);
|
|
86
|
+
this.lastRequestTime = Date.now();
|
|
87
|
+
if (response.status === 401 && attempt < maxRetries) {
|
|
88
|
+
// Token may have expired mid-request — force refresh and retry once.
|
|
89
|
+
this.authState = null;
|
|
90
|
+
const fresh = await this.GetAuth(imisAuth.Config);
|
|
91
|
+
imisAuth.Token = fresh.Token;
|
|
92
|
+
imisAuth.ExpiresAt = fresh.ExpiresAt;
|
|
93
|
+
effectiveHeaders['Authorization'] = `Bearer ${fresh.Token}`;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (this.ShouldRetry(response.status) && attempt < maxRetries) {
|
|
97
|
+
const delay = this.ComputeRetryDelay(response, attempt);
|
|
98
|
+
await this.Sleep(delay);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
return this.BuildRESTResponse(response);
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`iMIS API request failed after ${maxRetries} attempt(s): ${url}`);
|
|
104
|
+
}
|
|
105
|
+
NormalizeResponse(rawBody, responseDataKey) {
|
|
106
|
+
if (rawBody == null)
|
|
107
|
+
return [];
|
|
108
|
+
// PagedResult envelope → Items
|
|
109
|
+
if (this.IsPagedResult(rawBody)) {
|
|
110
|
+
return rawBody.Items ?? [];
|
|
111
|
+
}
|
|
112
|
+
// Explicit key provided (e.g., "Items")
|
|
113
|
+
if (responseDataKey) {
|
|
114
|
+
const record = rawBody;
|
|
115
|
+
const data = record[responseDataKey];
|
|
116
|
+
if (Array.isArray(data))
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(rawBody))
|
|
120
|
+
return rawBody;
|
|
121
|
+
// Single-object response (e.g., GET /{Entity}/{id})
|
|
122
|
+
return [rawBody];
|
|
123
|
+
}
|
|
124
|
+
ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, pageSize) {
|
|
125
|
+
if (paginationType === 'None')
|
|
126
|
+
return { HasMore: false };
|
|
127
|
+
const envelope = this.IsPagedResult(rawBody) ? rawBody : null;
|
|
128
|
+
if (!envelope) {
|
|
129
|
+
// Fallback: no PagedResult envelope → no more pages
|
|
130
|
+
return { HasMore: false };
|
|
131
|
+
}
|
|
132
|
+
const hasMore = envelope.HasNext ?? false;
|
|
133
|
+
const returnedCount = envelope.Items?.length ?? 0;
|
|
134
|
+
const nextOffset = (envelope.Offset ?? currentOffset) + returnedCount;
|
|
135
|
+
return {
|
|
136
|
+
HasMore: hasMore,
|
|
137
|
+
NextOffset: paginationType === 'Offset' ? nextOffset : undefined,
|
|
138
|
+
NextPage: paginationType === 'PageNumber' ? currentPage + 1 : undefined,
|
|
139
|
+
TotalRecords: envelope.Count,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
GetBaseURL(_companyIntegration, auth) {
|
|
143
|
+
return auth.BaseUrl;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* iMIS pagination uses `Offset` and `Limit` query parameters (capitalized).
|
|
147
|
+
* Override the base class helper to match iMIS's param names.
|
|
148
|
+
*/
|
|
149
|
+
BuildPaginatedURL(basePath, obj, page, offset, cursor, effectivePageSize) {
|
|
150
|
+
const pageSize = effectivePageSize ?? obj.DefaultPageSize ?? 100;
|
|
151
|
+
const separator = basePath.includes('?') ? '&' : '?';
|
|
152
|
+
switch (obj.PaginationType) {
|
|
153
|
+
case 'PageNumber':
|
|
154
|
+
return `${basePath}${separator}Page=${page}&Limit=${pageSize}`;
|
|
155
|
+
case 'Offset':
|
|
156
|
+
return `${basePath}${separator}Offset=${offset}&Limit=${pageSize}`;
|
|
157
|
+
case 'Cursor':
|
|
158
|
+
return cursor
|
|
159
|
+
? `${basePath}${separator}cursor=${encodeURIComponent(cursor)}&Limit=${pageSize}`
|
|
160
|
+
: `${basePath}${separator}Limit=${pageSize}`;
|
|
161
|
+
default:
|
|
162
|
+
return basePath;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Inject watermark filter (UpdatedOn=gt:<iso>) when an incremental sync is active.
|
|
167
|
+
* The iMIS REST API accepts operator-prefixed filter values (`gt:`, `ge:`, `lt:`) on list endpoints.
|
|
168
|
+
*/
|
|
169
|
+
AppendDefaultQueryParams(url, obj) {
|
|
170
|
+
let result = super.AppendDefaultQueryParams(url, obj);
|
|
171
|
+
if (this.currentWatermark && obj.SupportsIncrementalSync) {
|
|
172
|
+
const separator = result.includes('?') ? '&' : '?';
|
|
173
|
+
const param = `UpdatedOn=${encodeURIComponent('gt:' + this.currentWatermark)}`;
|
|
174
|
+
result = `${result}${separator}${param}`;
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
// ── Public API: Discovery, FetchChanges, CRUD ───────────────────
|
|
179
|
+
/**
|
|
180
|
+
* Test the connection by authenticating and hitting the /Party list endpoint
|
|
181
|
+
* with a minimal page size. Confirms both auth + data-plane connectivity.
|
|
182
|
+
*/
|
|
183
|
+
async TestConnection(companyIntegration, contextUser) {
|
|
184
|
+
try {
|
|
185
|
+
const auth = await this.GetAuth(companyIntegration, contextUser, true);
|
|
186
|
+
const url = `${auth.BaseUrl}/Party?Limit=1`;
|
|
187
|
+
const headers = this.BuildHeaders(auth);
|
|
188
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
189
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
190
|
+
return {
|
|
191
|
+
Success: true,
|
|
192
|
+
Message: `Connected to iMIS EMS at ${auth.BaseUrl}`,
|
|
193
|
+
ServerVersion: this.ExtractServerVersion(response.Headers),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { Success: false, Message: `iMIS responded HTTP ${response.Status}` };
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
200
|
+
return { Success: false, Message: `Connection failed: ${message}` };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Discover objects by merging:
|
|
205
|
+
* 1. Static IntegrationObject metadata (from MJ: Integration Objects table)
|
|
206
|
+
* 2. Runtime-discovered customer Business Objects via GET /BOEntityDefinition
|
|
207
|
+
* — marked with IsCustom=true when not already present in static metadata.
|
|
208
|
+
*/
|
|
209
|
+
async DiscoverObjects(companyIntegration, contextUser) {
|
|
210
|
+
const staticObjects = await super.DiscoverObjects(companyIntegration, contextUser);
|
|
211
|
+
const staticNames = new Set(staticObjects.map(o => o.Name.toLowerCase()));
|
|
212
|
+
try {
|
|
213
|
+
const customObjects = await this.DiscoverBusinessObjects(companyIntegration, contextUser);
|
|
214
|
+
const newCustom = customObjects.filter(co => !staticNames.has(co.Name.toLowerCase()));
|
|
215
|
+
return [...staticObjects, ...newCustom];
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
219
|
+
console.warn(`[iMIS] Business Object discovery failed, returning static objects only: ${message}`);
|
|
220
|
+
return staticObjects;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Discover fields by merging:
|
|
225
|
+
* 1. Static IntegrationObjectField metadata (from MJ: Integration Object Fields table)
|
|
226
|
+
* 2. Runtime-discovered fields via GET /{entity}/metadata
|
|
227
|
+
* — any live field not in the static metadata is appended with IsCustom=true
|
|
228
|
+
* (surfaced to callers via the IsForeignKey=false / IsReadOnly mix from the live API).
|
|
229
|
+
*/
|
|
230
|
+
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
231
|
+
const staticFields = await super.DiscoverFields(companyIntegration, objectName, contextUser);
|
|
232
|
+
try {
|
|
233
|
+
const liveFields = await this.DiscoverFieldsFromLiveAPI(companyIntegration, objectName, contextUser);
|
|
234
|
+
return this.MergeFieldSchemas(staticFields, liveFields);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
238
|
+
console.warn(`[iMIS] Live field discovery failed for "${objectName}", returning static fields: ${message}`);
|
|
239
|
+
return staticFields;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Override FetchChanges to stash the current watermark so AppendDefaultQueryParams
|
|
244
|
+
* can inject the UpdatedOn filter on list requests.
|
|
245
|
+
*/
|
|
246
|
+
async FetchChanges(ctx) {
|
|
247
|
+
this.currentWatermark = ctx.WatermarkValue;
|
|
248
|
+
try {
|
|
249
|
+
const result = await super.FetchChanges(ctx);
|
|
250
|
+
result.NewWatermarkValue = this.ComputeNewWatermark(result.Records, ctx.WatermarkValue);
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
this.currentWatermark = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ── Write operations ────────────────────────────────────────────
|
|
258
|
+
async CreateRecord(ctx) {
|
|
259
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
260
|
+
const contextUser = ctx.ContextUser;
|
|
261
|
+
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
262
|
+
const url = `${auth.BaseUrl}/${this.NormalizeObjectPath(ctx.ObjectName)}`;
|
|
263
|
+
const headers = this.BuildHeaders(auth);
|
|
264
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, ctx.Attributes);
|
|
265
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
266
|
+
const body = response.Body;
|
|
267
|
+
return {
|
|
268
|
+
Success: true,
|
|
269
|
+
ExternalID: this.ExtractIdFromRecord(body),
|
|
270
|
+
StatusCode: response.Status,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return this.BuildCRUDError(response, 'CreateRecord', ctx.ObjectName);
|
|
274
|
+
}
|
|
275
|
+
async UpdateRecord(ctx) {
|
|
276
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
277
|
+
const contextUser = ctx.ContextUser;
|
|
278
|
+
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
279
|
+
const objectPath = this.NormalizeObjectPath(ctx.ObjectName);
|
|
280
|
+
// iMIS PUT is a full replace — do a read-modify-write merge of Attributes
|
|
281
|
+
// into the current record so callers can supply partial updates.
|
|
282
|
+
const current = await this.FetchSingleRecord(auth, objectPath, ctx.ExternalID);
|
|
283
|
+
if (!current) {
|
|
284
|
+
return { Success: false, ErrorMessage: `Record ${ctx.ExternalID} not found`, StatusCode: 404 };
|
|
285
|
+
}
|
|
286
|
+
const merged = { ...current, ...ctx.Attributes };
|
|
287
|
+
const url = `${auth.BaseUrl}/${objectPath}/${encodeURIComponent(ctx.ExternalID)}`;
|
|
288
|
+
const headers = this.BuildHeaders(auth);
|
|
289
|
+
const response = await this.MakeHTTPRequest(auth, url, 'PUT', headers, merged);
|
|
290
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
291
|
+
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
|
|
292
|
+
}
|
|
293
|
+
return this.BuildCRUDError(response, 'UpdateRecord', ctx.ObjectName);
|
|
294
|
+
}
|
|
295
|
+
async DeleteRecord(ctx) {
|
|
296
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
297
|
+
const contextUser = ctx.ContextUser;
|
|
298
|
+
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
299
|
+
const url = `${auth.BaseUrl}/${this.NormalizeObjectPath(ctx.ObjectName)}/${encodeURIComponent(ctx.ExternalID)}`;
|
|
300
|
+
const headers = this.BuildHeaders(auth);
|
|
301
|
+
const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
|
|
302
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
303
|
+
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
|
|
304
|
+
}
|
|
305
|
+
return this.BuildCRUDError(response, 'DeleteRecord', ctx.ObjectName);
|
|
306
|
+
}
|
|
307
|
+
async GetRecord(ctx) {
|
|
308
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
309
|
+
const contextUser = ctx.ContextUser;
|
|
310
|
+
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
311
|
+
const record = await this.FetchSingleRecord(auth, this.NormalizeObjectPath(ctx.ObjectName), ctx.ExternalID);
|
|
312
|
+
if (!record)
|
|
313
|
+
return null;
|
|
314
|
+
return {
|
|
315
|
+
ExternalID: ctx.ExternalID,
|
|
316
|
+
ObjectType: ctx.ObjectName,
|
|
317
|
+
Fields: record,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
// ── Default configuration / field mappings ──────────────────────
|
|
321
|
+
GetDefaultConfiguration() {
|
|
322
|
+
return {
|
|
323
|
+
DefaultSchemaName: 'iMIS',
|
|
324
|
+
DefaultObjects: [],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
GetDefaultFieldMappings(objectName, _entityName) {
|
|
328
|
+
switch (objectName) {
|
|
329
|
+
case 'Party':
|
|
330
|
+
case 'Person':
|
|
331
|
+
return [
|
|
332
|
+
{ SourceFieldName: 'PartyId', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
333
|
+
{ SourceFieldName: 'FullName', DestinationFieldName: 'Name' },
|
|
334
|
+
{ SourceFieldName: 'PrimaryEmail', DestinationFieldName: 'Email' },
|
|
335
|
+
{ SourceFieldName: 'PrimaryPhone', DestinationFieldName: 'Phone' },
|
|
336
|
+
];
|
|
337
|
+
case 'Event':
|
|
338
|
+
return [
|
|
339
|
+
{ SourceFieldName: 'EventId', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
340
|
+
{ SourceFieldName: 'Name', DestinationFieldName: 'Name' },
|
|
341
|
+
{ SourceFieldName: 'StartDate', DestinationFieldName: 'StartDate' },
|
|
342
|
+
{ SourceFieldName: 'EndDate', DestinationFieldName: 'EndDate' },
|
|
343
|
+
];
|
|
344
|
+
default:
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// ─────────────────────────────────────────────────────────────────
|
|
349
|
+
// Private helpers
|
|
350
|
+
// ─────────────────────────────────────────────────────────────────
|
|
351
|
+
// ── Auth helpers ────────────────────────────────────────────────
|
|
352
|
+
/**
|
|
353
|
+
* Returns a valid auth context, refreshing the access token as needed.
|
|
354
|
+
* Accepts either (companyIntegration, contextUser) for the first call
|
|
355
|
+
* or a pre-parsed config for silent refresh from MakeHTTPRequest.
|
|
356
|
+
*/
|
|
357
|
+
async GetAuth(source, contextUser, forceRefresh = false) {
|
|
358
|
+
if (!forceRefresh && this.authState && this.IsTokenValid(this.authState)) {
|
|
359
|
+
return this.authState;
|
|
360
|
+
}
|
|
361
|
+
const config = this.IsConfig(source)
|
|
362
|
+
? source
|
|
363
|
+
: await this.ParseConfig(source, contextUser);
|
|
364
|
+
this.authState = await this.AcquireToken(config);
|
|
365
|
+
return this.authState;
|
|
366
|
+
}
|
|
367
|
+
IsConfig(source) {
|
|
368
|
+
return source.BaseUrl !== undefined
|
|
369
|
+
&& source.Username !== undefined;
|
|
370
|
+
}
|
|
371
|
+
IsTokenValid(auth) {
|
|
372
|
+
return auth.ExpiresAt.getTime() > Date.now() + TOKEN_REFRESH_BUFFER_MS;
|
|
373
|
+
}
|
|
374
|
+
async AcquireToken(config) {
|
|
375
|
+
const baseUrl = this.StripTrailingSlash(config.BaseUrl);
|
|
376
|
+
const tokenUrl = this.ResolveTokenEndpoint(baseUrl);
|
|
377
|
+
const body = this.BuildTokenRequestBody(config);
|
|
378
|
+
const response = await fetch(tokenUrl, {
|
|
379
|
+
method: 'POST',
|
|
380
|
+
headers: {
|
|
381
|
+
'Accept': 'application/json',
|
|
382
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
383
|
+
},
|
|
384
|
+
body,
|
|
385
|
+
});
|
|
386
|
+
if (!response.ok) {
|
|
387
|
+
const text = await response.text();
|
|
388
|
+
throw new Error(`iMIS token request failed (${response.status}): ${text.slice(0, 300)}`);
|
|
389
|
+
}
|
|
390
|
+
const payload = await response.json();
|
|
391
|
+
const expiresAt = new Date(Date.now() + (payload.expires_in ?? 3600) * 1000);
|
|
392
|
+
if (payload.refresh_token) {
|
|
393
|
+
config.RefreshToken = payload.refresh_token;
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
Token: payload.access_token,
|
|
397
|
+
ExpiresAt: expiresAt,
|
|
398
|
+
Config: config,
|
|
399
|
+
BaseUrl: baseUrl,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* iMIS issues tokens at the scheduler path, not under /api/. Strip /api[/]
|
|
404
|
+
* from the BaseUrl when constructing the token endpoint.
|
|
405
|
+
*/
|
|
406
|
+
ResolveTokenEndpoint(baseUrl) {
|
|
407
|
+
const trimmed = baseUrl.replace(/\/api\/?$/i, '');
|
|
408
|
+
// Vendor-canonical path is lowercase '/token/' per developer.imis.com docs.
|
|
409
|
+
// IIS is normally case-insensitive on URL paths so '/Token' usually works,
|
|
410
|
+
// but a few customer reverse-proxies enforce case strictness — match docs.
|
|
411
|
+
return `${trimmed}/token/`;
|
|
412
|
+
}
|
|
413
|
+
BuildTokenRequestBody(config) {
|
|
414
|
+
const params = new URLSearchParams();
|
|
415
|
+
if (config.ClientId && config.ClientSecret && config.RefreshToken) {
|
|
416
|
+
params.set('grant_type', 'refresh_token');
|
|
417
|
+
params.set('refresh_token', config.RefreshToken);
|
|
418
|
+
params.set('client_id', config.ClientId);
|
|
419
|
+
params.set('client_secret', config.ClientSecret);
|
|
420
|
+
return params.toString();
|
|
421
|
+
}
|
|
422
|
+
params.set('grant_type', 'password');
|
|
423
|
+
params.set('username', config.Username);
|
|
424
|
+
params.set('password', config.Password);
|
|
425
|
+
return params.toString();
|
|
426
|
+
}
|
|
427
|
+
// ── Configuration parsing ───────────────────────────────────────
|
|
428
|
+
async ParseConfig(companyIntegration, contextUser) {
|
|
429
|
+
if (companyIntegration.CredentialID) {
|
|
430
|
+
const config = await this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser);
|
|
431
|
+
if (config)
|
|
432
|
+
return config;
|
|
433
|
+
}
|
|
434
|
+
if (companyIntegration.Configuration) {
|
|
435
|
+
const parsed = JSON.parse(companyIntegration.Configuration);
|
|
436
|
+
return this.ExtractConfig(parsed);
|
|
437
|
+
}
|
|
438
|
+
throw new Error('iMIS connector requires either CredentialID or Configuration JSON');
|
|
439
|
+
}
|
|
440
|
+
async ParseConfigFromCredential(credentialID, contextUser, provider) {
|
|
441
|
+
const md = provider ?? new Metadata();
|
|
442
|
+
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
443
|
+
const loaded = await credential.Load(credentialID);
|
|
444
|
+
if (!loaded || !credential.Values)
|
|
445
|
+
return null;
|
|
446
|
+
const values = JSON.parse(credential.Values);
|
|
447
|
+
return this.ExtractConfig(values);
|
|
448
|
+
}
|
|
449
|
+
ExtractConfig(values) {
|
|
450
|
+
const get = (...keys) => {
|
|
451
|
+
for (const key of keys) {
|
|
452
|
+
const hit = Object.entries(values).find(([k]) => k.toLowerCase() === key.toLowerCase());
|
|
453
|
+
if (hit)
|
|
454
|
+
return String(hit[1] ?? '');
|
|
455
|
+
}
|
|
456
|
+
return undefined;
|
|
457
|
+
};
|
|
458
|
+
const baseUrl = get('BaseUrl', 'baseUrl', 'base_url');
|
|
459
|
+
const username = get('Username', 'username', 'user');
|
|
460
|
+
const password = get('Password', 'password', 'pass');
|
|
461
|
+
if (!baseUrl)
|
|
462
|
+
throw new Error('iMIS configuration missing required field: BaseUrl');
|
|
463
|
+
if (!username)
|
|
464
|
+
throw new Error('iMIS configuration missing required field: Username');
|
|
465
|
+
if (!password)
|
|
466
|
+
throw new Error('iMIS configuration missing required field: Password');
|
|
467
|
+
return {
|
|
468
|
+
BaseUrl: this.StripTrailingSlash(baseUrl),
|
|
469
|
+
Username: username,
|
|
470
|
+
Password: password,
|
|
471
|
+
ClientId: get('ClientId', 'clientId', 'client_id'),
|
|
472
|
+
ClientSecret: get('ClientSecret', 'clientSecret', 'client_secret'),
|
|
473
|
+
RefreshToken: get('RefreshToken', 'refreshToken', 'refresh_token'),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
// ── Runtime discovery (BOs + field metadata) ────────────────────
|
|
477
|
+
async DiscoverBusinessObjects(companyIntegration, contextUser) {
|
|
478
|
+
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
479
|
+
const url = `${auth.BaseUrl}/BOEntityDefinition?Limit=500`;
|
|
480
|
+
const headers = this.BuildHeaders(auth);
|
|
481
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
482
|
+
if (response.Status < 200 || response.Status >= 300)
|
|
483
|
+
return [];
|
|
484
|
+
const records = this.NormalizeResponse(response.Body, 'Items');
|
|
485
|
+
return records
|
|
486
|
+
.filter(bo => !!bo.Name)
|
|
487
|
+
.map(bo => ({
|
|
488
|
+
Name: String(bo.Name),
|
|
489
|
+
Label: bo.DisplayName ?? String(bo.Name),
|
|
490
|
+
Description: bo.Description ?? `Customer-defined Business Object (runtime-discovered)`,
|
|
491
|
+
SupportsIncrementalSync: true,
|
|
492
|
+
SupportsWrite: true,
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
async DiscoverFieldsFromLiveAPI(companyIntegration, objectName, contextUser) {
|
|
496
|
+
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
497
|
+
const path = this.ResolveObjectPath(companyIntegration.IntegrationID, objectName);
|
|
498
|
+
const url = `${auth.BaseUrl}${path}/metadata`;
|
|
499
|
+
const headers = this.BuildHeaders(auth);
|
|
500
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
501
|
+
if (response.Status < 200 || response.Status >= 300)
|
|
502
|
+
return [];
|
|
503
|
+
const meta = response.Body;
|
|
504
|
+
const propDefs = meta?.PropertyDefinitions ?? [];
|
|
505
|
+
return propDefs
|
|
506
|
+
.filter(p => !!p.Name)
|
|
507
|
+
.map(p => this.PropertyDefinitionToFieldSchema(p));
|
|
508
|
+
}
|
|
509
|
+
PropertyDefinitionToFieldSchema(prop) {
|
|
510
|
+
const rawType = (prop.PropertyType ?? prop.PropertyTypeData?.Name ?? 'string').toLowerCase();
|
|
511
|
+
const genericType = IMIS_TYPE_MAP[rawType] ?? 'string';
|
|
512
|
+
return {
|
|
513
|
+
Name: String(prop.Name),
|
|
514
|
+
Label: prop.DisplayName ?? String(prop.Name),
|
|
515
|
+
Description: prop.Description,
|
|
516
|
+
DataType: genericType,
|
|
517
|
+
IsRequired: !!prop.IsRequired,
|
|
518
|
+
IsUniqueKey: !!prop.IsPrimaryKey,
|
|
519
|
+
IsReadOnly: !!prop.IsReadOnly,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Merge live-discovered fields with static metadata. Live-only fields are
|
|
524
|
+
* treated as custom — surfaced with whatever properties the live API reports.
|
|
525
|
+
* Static fields override live fields on matching names (static metadata
|
|
526
|
+
* reflects the curated MJ schema).
|
|
527
|
+
*/
|
|
528
|
+
MergeFieldSchemas(staticFields, liveFields) {
|
|
529
|
+
const staticNames = new Set(staticFields.map(f => f.Name.toLowerCase()));
|
|
530
|
+
const liveOnly = liveFields.filter(f => !staticNames.has(f.Name.toLowerCase()));
|
|
531
|
+
return [...staticFields, ...liveOnly];
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Resolve an object name to its API path. Prefers the configured APIPath
|
|
535
|
+
* from IntegrationObject metadata; falls back to `/{ObjectName}` for
|
|
536
|
+
* runtime-discovered Business Objects.
|
|
537
|
+
*/
|
|
538
|
+
ResolveObjectPath(integrationID, objectName) {
|
|
539
|
+
try {
|
|
540
|
+
const obj = this.GetCachedObject(integrationID, objectName);
|
|
541
|
+
if (obj.APIPath) {
|
|
542
|
+
return obj.APIPath.startsWith('/') ? obj.APIPath : `/${obj.APIPath}`;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// Not in static metadata — this is a runtime-discovered Business Object.
|
|
547
|
+
}
|
|
548
|
+
return `/${objectName}`;
|
|
549
|
+
}
|
|
550
|
+
/** Derive the URL path segment used by CRUD operations (no leading slash). */
|
|
551
|
+
NormalizeObjectPath(objectName) {
|
|
552
|
+
return objectName.startsWith('/') ? objectName.slice(1) : objectName;
|
|
553
|
+
}
|
|
554
|
+
// ── Record / watermark helpers ──────────────────────────────────
|
|
555
|
+
async FetchSingleRecord(auth, objectPath, id) {
|
|
556
|
+
const url = `${auth.BaseUrl}/${objectPath}/${encodeURIComponent(id)}`;
|
|
557
|
+
const headers = this.BuildHeaders(auth);
|
|
558
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
559
|
+
if (response.Status < 200 || response.Status >= 300)
|
|
560
|
+
return null;
|
|
561
|
+
if (response.Body == null || typeof response.Body !== 'object')
|
|
562
|
+
return null;
|
|
563
|
+
return response.Body;
|
|
564
|
+
}
|
|
565
|
+
ExtractIdFromRecord(body) {
|
|
566
|
+
const candidates = ['Id', 'ID', 'PartyId', 'UniformId', 'EventId', 'InvoiceId', 'PaymentId'];
|
|
567
|
+
for (const key of candidates) {
|
|
568
|
+
const value = body[key];
|
|
569
|
+
if (value !== undefined && value !== null)
|
|
570
|
+
return String(value);
|
|
571
|
+
}
|
|
572
|
+
return '';
|
|
573
|
+
}
|
|
574
|
+
ComputeNewWatermark(records, current) {
|
|
575
|
+
const candidateKeys = ['UpdatedOn', 'UpdateDate', 'ModifiedOn', 'LastUpdatedAt'];
|
|
576
|
+
let latest;
|
|
577
|
+
for (const record of records) {
|
|
578
|
+
for (const key of candidateKeys) {
|
|
579
|
+
const value = record.Fields[key];
|
|
580
|
+
if (typeof value === 'string' && (!latest || value > latest))
|
|
581
|
+
latest = value;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (!latest)
|
|
585
|
+
return current ?? undefined;
|
|
586
|
+
if (current && latest <= current)
|
|
587
|
+
return current;
|
|
588
|
+
return latest;
|
|
589
|
+
}
|
|
590
|
+
// ── HTTP helpers ────────────────────────────────────────────────
|
|
591
|
+
async ExecuteFetch(url, method, headers, body, timeoutMs) {
|
|
592
|
+
const controller = new AbortController();
|
|
593
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
594
|
+
try {
|
|
595
|
+
return await fetch(url, {
|
|
596
|
+
method,
|
|
597
|
+
headers,
|
|
598
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
599
|
+
signal: controller.signal,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
clearTimeout(timer);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async BuildRESTResponse(response) {
|
|
607
|
+
const headers = {};
|
|
608
|
+
response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
|
|
609
|
+
const contentType = headers['content-type'] ?? '';
|
|
610
|
+
let bodyValue;
|
|
611
|
+
if (contentType.includes('application/json')) {
|
|
612
|
+
bodyValue = await this.ParseJsonSafely(response);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
bodyValue = await response.text();
|
|
616
|
+
}
|
|
617
|
+
return { Status: response.status, Body: bodyValue, Headers: headers };
|
|
618
|
+
}
|
|
619
|
+
async ParseJsonSafely(response) {
|
|
620
|
+
try {
|
|
621
|
+
return await response.json();
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
ShouldRetry(status) {
|
|
628
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
629
|
+
}
|
|
630
|
+
ComputeRetryDelay(response, attempt) {
|
|
631
|
+
const retryAfter = response.headers.get('retry-after');
|
|
632
|
+
if (retryAfter) {
|
|
633
|
+
const seconds = Number.parseInt(retryAfter, 10);
|
|
634
|
+
if (!Number.isNaN(seconds) && seconds > 0)
|
|
635
|
+
return seconds * 1000;
|
|
636
|
+
}
|
|
637
|
+
return Math.min(Math.pow(2, attempt) * 1000, 15_000);
|
|
638
|
+
}
|
|
639
|
+
async Throttle(minIntervalMs) {
|
|
640
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
641
|
+
if (elapsed < minIntervalMs)
|
|
642
|
+
await this.Sleep(minIntervalMs - elapsed);
|
|
643
|
+
}
|
|
644
|
+
Sleep(ms) {
|
|
645
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
646
|
+
}
|
|
647
|
+
ExtractServerVersion(headers) {
|
|
648
|
+
return headers['x-imis-version'] ?? headers['server'] ?? 'iMIS REST API';
|
|
649
|
+
}
|
|
650
|
+
IsPagedResult(body) {
|
|
651
|
+
if (body == null || typeof body !== 'object' || Array.isArray(body))
|
|
652
|
+
return false;
|
|
653
|
+
const record = body;
|
|
654
|
+
return Array.isArray(record['Items']) && ('Offset' in record || 'Limit' in record || 'HasNext' in record);
|
|
655
|
+
}
|
|
656
|
+
StripTrailingSlash(url) {
|
|
657
|
+
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
658
|
+
}
|
|
659
|
+
BuildCRUDError(response, operation, objectName) {
|
|
660
|
+
const body = response.Body;
|
|
661
|
+
let detail;
|
|
662
|
+
if (typeof body === 'string') {
|
|
663
|
+
detail = body.slice(0, 500);
|
|
664
|
+
}
|
|
665
|
+
else if (body && typeof body === 'object') {
|
|
666
|
+
const asRecord = body;
|
|
667
|
+
const msg = asRecord['Message'] ?? asRecord['error_description'] ?? asRecord['error'];
|
|
668
|
+
detail = msg !== undefined ? String(msg) : JSON.stringify(body).slice(0, 500);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
detail = `HTTP ${response.Status}`;
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
Success: false,
|
|
675
|
+
ErrorMessage: `iMIS ${operation} failed on ${objectName}: ${detail}`,
|
|
676
|
+
StatusCode: response.Status,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
IMISConnector = __decorate([
|
|
681
|
+
RegisterClass(BaseIntegrationConnector, 'IMISConnector')
|
|
682
|
+
], IMISConnector);
|
|
683
|
+
export { IMISConnector };
|
|
684
|
+
/** Tree-shaking prevention — import and call from module entry point */
|
|
685
|
+
export function LoadIMISConnector() { }
|
|
686
|
+
//# sourceMappingURL=IMISConnector.js.map
|