@memberjunction/integration-connectors 5.35.0 → 5.37.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.
Files changed (81) hide show
  1. package/dist/AptifyConnector.d.ts +88 -0
  2. package/dist/AptifyConnector.d.ts.map +1 -0
  3. package/dist/AptifyConnector.js +737 -0
  4. package/dist/AptifyConnector.js.map +1 -0
  5. package/dist/BettyConnector.d.ts +288 -0
  6. package/dist/BettyConnector.d.ts.map +1 -0
  7. package/dist/BettyConnector.js +909 -0
  8. package/dist/BettyConnector.js.map +1 -0
  9. package/dist/BlackbaudConnector.d.ts +64 -0
  10. package/dist/BlackbaudConnector.d.ts.map +1 -0
  11. package/dist/BlackbaudConnector.js +916 -0
  12. package/dist/BlackbaudConnector.js.map +1 -0
  13. package/dist/ConstantContactConnector.d.ts +126 -0
  14. package/dist/ConstantContactConnector.d.ts.map +1 -0
  15. package/dist/ConstantContactConnector.js +636 -0
  16. package/dist/ConstantContactConnector.js.map +1 -0
  17. package/dist/GrowthZoneConnector.d.ts +185 -0
  18. package/dist/GrowthZoneConnector.d.ts.map +1 -0
  19. package/dist/GrowthZoneConnector.js +745 -0
  20. package/dist/GrowthZoneConnector.js.map +1 -0
  21. package/dist/HubSpotConnector.d.ts +2 -2
  22. package/dist/HubSpotConnector.d.ts.map +1 -1
  23. package/dist/HubSpotConnector.js +3 -3
  24. package/dist/HubSpotConnector.js.map +1 -1
  25. package/dist/IMISConnector.d.ts +155 -0
  26. package/dist/IMISConnector.d.ts.map +1 -0
  27. package/dist/IMISConnector.js +686 -0
  28. package/dist/IMISConnector.js.map +1 -0
  29. package/dist/MJToMJConnector.d.ts +279 -0
  30. package/dist/MJToMJConnector.d.ts.map +1 -0
  31. package/dist/MJToMJConnector.js +893 -0
  32. package/dist/MJToMJConnector.js.map +1 -0
  33. package/dist/MagnetMailConnector.d.ts +163 -0
  34. package/dist/MagnetMailConnector.d.ts.map +1 -0
  35. package/dist/MagnetMailConnector.js +816 -0
  36. package/dist/MagnetMailConnector.js.map +1 -0
  37. package/dist/MailchimpConnector.d.ts +188 -0
  38. package/dist/MailchimpConnector.d.ts.map +1 -0
  39. package/dist/MailchimpConnector.js +753 -0
  40. package/dist/MailchimpConnector.js.map +1 -0
  41. package/dist/NetForumConnector.d.ts +51 -0
  42. package/dist/NetForumConnector.d.ts.map +1 -0
  43. package/dist/NetForumConnector.js +419 -0
  44. package/dist/NetForumConnector.js.map +1 -0
  45. package/dist/NetSuiteConnector.d.ts +70 -0
  46. package/dist/NetSuiteConnector.d.ts.map +1 -0
  47. package/dist/NetSuiteConnector.js +605 -0
  48. package/dist/NetSuiteConnector.js.map +1 -0
  49. package/dist/NimbleAMSConnector.d.ts +49 -0
  50. package/dist/NimbleAMSConnector.d.ts.map +1 -0
  51. package/dist/NimbleAMSConnector.js +408 -0
  52. package/dist/NimbleAMSConnector.js.map +1 -0
  53. package/dist/PropFuelConnector.d.ts +68 -0
  54. package/dist/PropFuelConnector.d.ts.map +1 -0
  55. package/dist/PropFuelConnector.js +514 -0
  56. package/dist/PropFuelConnector.js.map +1 -0
  57. package/dist/RasaConnector.d.ts +26 -0
  58. package/dist/RasaConnector.d.ts.map +1 -1
  59. package/dist/RasaConnector.js +233 -1
  60. package/dist/RasaConnector.js.map +1 -1
  61. package/dist/Reach360Connector.d.ts +165 -0
  62. package/dist/Reach360Connector.d.ts.map +1 -0
  63. package/dist/Reach360Connector.js +710 -0
  64. package/dist/Reach360Connector.js.map +1 -0
  65. package/dist/SharePointConnector.d.ts +181 -0
  66. package/dist/SharePointConnector.d.ts.map +1 -0
  67. package/dist/SharePointConnector.js +796 -0
  68. package/dist/SharePointConnector.js.map +1 -0
  69. package/dist/WicketConnector.d.ts +1 -0
  70. package/dist/WicketConnector.d.ts.map +1 -1
  71. package/dist/WicketConnector.js +6 -0
  72. package/dist/WicketConnector.js.map +1 -1
  73. package/dist/WildApricotConnector.d.ts +188 -0
  74. package/dist/WildApricotConnector.d.ts.map +1 -0
  75. package/dist/WildApricotConnector.js +1092 -0
  76. package/dist/WildApricotConnector.js.map +1 -0
  77. package/dist/index.d.ts +16 -0
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +16 -0
  80. package/dist/index.js.map +1 -1
  81. 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:&lt;iso-date&gt; 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