@memberjunction/integration-connectors 5.41.0 → 5.43.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 (92) hide show
  1. package/dist/CventConnector.d.ts +146 -0
  2. package/dist/CventConnector.d.ts.map +1 -0
  3. package/dist/CventConnector.js +540 -0
  4. package/dist/CventConnector.js.map +1 -0
  5. package/dist/DynamicsDataverseConnector.d.ts +296 -0
  6. package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
  7. package/dist/DynamicsDataverseConnector.js +790 -0
  8. package/dist/DynamicsDataverseConnector.js.map +1 -0
  9. package/dist/FontevaConnector.d.ts +153 -0
  10. package/dist/FontevaConnector.d.ts.map +1 -0
  11. package/dist/FontevaConnector.js +421 -0
  12. package/dist/FontevaConnector.js.map +1 -0
  13. package/dist/GrowthZoneConnector.d.ts +123 -125
  14. package/dist/GrowthZoneConnector.d.ts.map +1 -1
  15. package/dist/GrowthZoneConnector.js +456 -499
  16. package/dist/GrowthZoneConnector.js.map +1 -1
  17. package/dist/HivebriteConnector.d.ts +151 -0
  18. package/dist/HivebriteConnector.d.ts.map +1 -0
  19. package/dist/HivebriteConnector.js +533 -0
  20. package/dist/HivebriteConnector.js.map +1 -0
  21. package/dist/IMISConnector.d.ts +112 -96
  22. package/dist/IMISConnector.d.ts.map +1 -1
  23. package/dist/IMISConnector.js +430 -417
  24. package/dist/IMISConnector.js.map +1 -1
  25. package/dist/MemberSuiteConnector.d.ts +185 -0
  26. package/dist/MemberSuiteConnector.d.ts.map +1 -0
  27. package/dist/MemberSuiteConnector.js +762 -0
  28. package/dist/MemberSuiteConnector.js.map +1 -0
  29. package/dist/NeonCRMConnector.d.ts +273 -0
  30. package/dist/NeonCRMConnector.d.ts.map +1 -0
  31. package/dist/NeonCRMConnector.js +1039 -0
  32. package/dist/NeonCRMConnector.js.map +1 -0
  33. package/dist/NetForumConnector.d.ts +140 -47
  34. package/dist/NetForumConnector.d.ts.map +1 -1
  35. package/dist/NetForumConnector.js +668 -395
  36. package/dist/NetForumConnector.js.map +1 -1
  37. package/dist/NetSuiteConnector.d.ts +116 -50
  38. package/dist/NetSuiteConnector.d.ts.map +1 -1
  39. package/dist/NetSuiteConnector.js +608 -510
  40. package/dist/NetSuiteConnector.js.map +1 -1
  41. package/dist/NimbleAMSConnector.d.ts +104 -11
  42. package/dist/NimbleAMSConnector.d.ts.map +1 -1
  43. package/dist/NimbleAMSConnector.js +552 -258
  44. package/dist/NimbleAMSConnector.js.map +1 -1
  45. package/dist/NoviConnector.d.ts +178 -0
  46. package/dist/NoviConnector.d.ts.map +1 -0
  47. package/dist/NoviConnector.js +566 -0
  48. package/dist/NoviConnector.js.map +1 -0
  49. package/dist/ORCIDConnector.d.ts +207 -0
  50. package/dist/ORCIDConnector.d.ts.map +1 -0
  51. package/dist/ORCIDConnector.js +708 -0
  52. package/dist/ORCIDConnector.js.map +1 -0
  53. package/dist/OpenWaterConnector.d.ts +173 -0
  54. package/dist/OpenWaterConnector.d.ts.map +1 -0
  55. package/dist/OpenWaterConnector.js +893 -0
  56. package/dist/OpenWaterConnector.js.map +1 -0
  57. package/dist/PathLMSConnector.d.ts +395 -0
  58. package/dist/PathLMSConnector.d.ts.map +1 -0
  59. package/dist/PathLMSConnector.js +1281 -0
  60. package/dist/PathLMSConnector.js.map +1 -0
  61. package/dist/PheedLoopConnector.d.ts +119 -0
  62. package/dist/PheedLoopConnector.d.ts.map +1 -0
  63. package/dist/PheedLoopConnector.js +429 -0
  64. package/dist/PheedLoopConnector.js.map +1 -0
  65. package/dist/PropFuelConnector.d.ts +135 -61
  66. package/dist/PropFuelConnector.d.ts.map +1 -1
  67. package/dist/PropFuelConnector.js +366 -467
  68. package/dist/PropFuelConnector.js.map +1 -1
  69. package/dist/RhythmConnector.d.ts +188 -0
  70. package/dist/RhythmConnector.d.ts.map +1 -0
  71. package/dist/RhythmConnector.js +784 -0
  72. package/dist/RhythmConnector.js.map +1 -0
  73. package/dist/SalesforceConnector.d.ts +69 -3
  74. package/dist/SalesforceConnector.d.ts.map +1 -1
  75. package/dist/SalesforceConnector.js +188 -166
  76. package/dist/SalesforceConnector.js.map +1 -1
  77. package/dist/SharePointConnector.d.ts +37 -30
  78. package/dist/SharePointConnector.d.ts.map +1 -1
  79. package/dist/SharePointConnector.js +163 -243
  80. package/dist/SharePointConnector.js.map +1 -1
  81. package/dist/YourMembershipConnector.d.ts.map +1 -1
  82. package/dist/YourMembershipConnector.js +13 -5
  83. package/dist/YourMembershipConnector.js.map +1 -1
  84. package/dist/index.d.ts +14 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +14 -3
  87. package/dist/index.js.map +1 -1
  88. package/package.json +8 -7
  89. package/dist/BettyConnector.d.ts +0 -288
  90. package/dist/BettyConnector.d.ts.map +0 -1
  91. package/dist/BettyConnector.js +0 -909
  92. package/dist/BettyConnector.js.map +0 -1
@@ -4,15 +4,66 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
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
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
+ /**
8
+ * IMISConnector — Integration connector for iMIS EMS (Advanced Solutions International)
9
+ * REST API.
10
+ *
11
+ * ── Auth: OAuth 2.0 PASSWORD grant (resource-owner) ─────────────────────────
12
+ * Mints/refreshes a bearer ACCESS TOKEN via the shared {@link OAuth2TokenManager}
13
+ * (no inlined token/crypto logic). iMIS issues tokens via the resource-owner
14
+ * `grant_type=password` flow — username + password POSTed to the token endpoint:
15
+ * - default = `{BaseURL}/token`
16
+ * - override = the credential's `TokenURL` (when supplied)
17
+ * - fallback = the legacy `{BaseURL}/asiScheduler/token` path (older iMIS builds)
18
+ * iMIS does NOT require client_id/client_secret for the password grant, so the
19
+ * OAuth2TokenManager request is configured with empty client credentials in the
20
+ * body. Every API request sends `Authorization: Bearer {accessToken}`.
21
+ *
22
+ * ── Base URL ────────────────────────────────────────────────────────────────
23
+ * Comes from the credential's `BaseURL` (the tenant's iMIS API endpoint) — never
24
+ * a hardcoded host. Credential bytes are resolved at runtime, never at build.
25
+ *
26
+ * ── Catalog (metadata-driven, NOT hardcoded) ───────────────────────────────
27
+ * The Declared core object/field set comes from the seeded metadata loaded into
28
+ * the IntegrationEngineBase cache. The connector NEVER bakes an object/field catalog
29
+ * into code. DiscoverObjects/DiscoverFields express only the MECHANISM: they call the
30
+ * auth-gated /EntityDefinition + /{entity}/metadata endpoints at runtime to enumerate
31
+ * the tenant's Business-Object catalog + per-object fields, ADDITIVE to the Declared
32
+ * baseline. DiscoveryIsAuthoritative stays false (default) — these scoped enumerations
33
+ * never deactivate Declared metadata on absence.
34
+ *
35
+ * ── Pagination & incremental ───────────────────────────────────────────────
36
+ * iMIS uses the PagedResult envelope (`{ Items, Offset, Limit, Count, HasNext }`) with
37
+ * capitalized `Offset`/`Limit` query params; ExtractPaginationInfo reads HasNext/Offset/Limit
38
+ * and advances Offset by the returned count until HasNext is false. NormalizeResponse
39
+ * unwraps the Items collection. Incremental sync is metadata-driven: an IO with
40
+ * SupportsIncrementalSync=true emits its `IncrementalWatermarkField` as a `gt:` filter.
41
+ *
42
+ * ── Write ───────────────────────────────────────────────────────────────────
43
+ * CRUD rides the generic BaseRESTIntegrationConnector per-operation path: Create/Update/Delete
44
+ * read the per-operation IO columns (CreateAPIPath/Method/BodyShape/BodyKey/IDLocation, Update*,
45
+ * Delete*), creates route through BuildCreatedResult (fail loudly on empty ID), and DeleteMethod
46
+ * is metadata-driven (not assumed DELETE). Capability flags follow the per-operation columns the
47
+ * metadata declares — no hardcoded `true`, no 501 stubs. Full-record pass-through (Fields=raw).
48
+ *
49
+ * Reference: https://developer.imis.com/docs/imis-rest-api-data-models-and-swagger-json-files
50
+ */
7
51
  import { RegisterClass } from '@memberjunction/global';
8
52
  import { Metadata } from '@memberjunction/core';
9
- import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
10
- // ─── Constants ─────────────────────────────────────────────────────────
53
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, OAuth2TokenManager, } from '@memberjunction/integration-engine';
54
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
55
+ // ─── Constants ───────────────────────────────────────────────────────
56
+ /** The canonical MJ: Integrations.Name — part of the three-way invariant. */
57
+ const INTEGRATION_NAME = 'iMIS';
58
+ /** Default token endpoint path appended to the base when no TokenURL override is supplied. */
59
+ const DEFAULT_TOKEN_PATH = '/token';
60
+ /** Legacy token endpoint path for older iMIS deployments (documented fallback). */
61
+ const LEGACY_TOKEN_PATH = '/asiScheduler/token';
11
62
  const DEFAULT_MAX_RETRIES = 3;
12
63
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
13
64
  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. */
65
+ const DEFAULT_PAGE_SIZE = 100;
66
+ /** iMIS-to-generic type mapping used when merging live-discovered field metadata. */
16
67
  const IMIS_TYPE_MAP = {
17
68
  'string': 'string',
18
69
  'datetime': 'datetime',
@@ -29,87 +80,141 @@ const IMIS_TYPE_MAP = {
29
80
  'uuid': 'uuid',
30
81
  'monetaryamountdata': 'decimal',
31
82
  };
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
- */
83
+ // ─── Connector Implementation ────────────────────────────────────────
48
84
  let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
49
85
  constructor() {
50
86
  super(...arguments);
51
- this.authState = null;
87
+ /** Cached auth context for the current sync run. */
88
+ this.authCache = null;
89
+ /** Shared OAuth2 token manager — owns the token round-trip + cache. */
90
+ this.tokenManager = new OAuth2TokenManager();
91
+ /** Timestamp of the last API request, for client-side throttling. */
52
92
  this.lastRequestTime = 0;
53
- /** Current watermark for the in-flight FetchChanges — used by AppendDefaultQueryParams */
54
- this.currentWatermark = null;
55
93
  }
56
- // ── Capability flags ────────────────────────────────────────────
57
- get SupportsCreate() { return true; }
58
- get SupportsUpdate() { return true; }
59
- get SupportsDelete() { return true; }
94
+ // Returns the EXACT MJ: Integrations.Name string LITERAL so the T1 ThreeWayName invariant can
95
+ // statically parse the getter's returned value from connector source.
96
+ get IntegrationName() { return 'iMIS'; }
97
+ // ── Capability getters: METADATA-DRIVEN (no hardcoded answer) ─────
98
+ //
99
+ // Write capability FOLLOWS the per-operation CRUD columns on the cached IntegrationObjects
100
+ // (Declared metadata). An object is create-capable when it declares both CreateAPIPath +
101
+ // CreateMethod; same for update/delete. With no write metadata authored the surface is
102
+ // read-only (returns false) — but the moment a writable object's per-operation columns are
103
+ // populated, the capability flips on and the base generic CRUD path executes it.
104
+ get SupportsCreate() {
105
+ return this.anyObjectDeclares(o => !!o.CreateAPIPath && !!o.CreateMethod);
106
+ }
107
+ get SupportsUpdate() {
108
+ return this.anyObjectDeclares(o => !!o.UpdateAPIPath && !!o.UpdateMethod);
109
+ }
110
+ get SupportsDelete() {
111
+ return this.anyObjectDeclares(o => !!o.DeleteAPIPath && !!o.DeleteMethod);
112
+ }
60
113
  get SupportsSearch() { return false; }
61
114
  get SupportsListing() { return false; }
62
- get IntegrationName() { return 'iMIS'; }
63
- // ── Auth + transport (abstract methods from BaseRESTIntegrationConnector) ────
115
+ /** True when any cached IntegrationObject satisfies the predicate. []→false when the engine
116
+ * cache is unavailable (e.g. capability probed before configuration) — fail-safe read-only. */
117
+ anyObjectDeclares(pred) {
118
+ const integration = IntegrationEngineBase.Instance.GetIntegrationByName(INTEGRATION_NAME);
119
+ if (!integration)
120
+ return false;
121
+ return IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integration.ID).some(pred);
122
+ }
123
+ // ─── BaseRESTIntegrationConnector abstract methods ──────────────
124
+ /**
125
+ * OAuth 2.0 password-grant authentication. Mints/refreshes the access token via the shared
126
+ * {@link OAuth2TokenManager} (no inlined token/crypto logic). One token exchange per run is
127
+ * cached and reused across every request.
128
+ */
64
129
  async Authenticate(companyIntegration, contextUser) {
65
- return this.GetAuth(companyIntegration, contextUser);
130
+ if (this.authCache)
131
+ return this.authCache;
132
+ const config = await this.ParseConfig(companyIntegration, contextUser);
133
+ const baseUrl = this.ResolveBaseUrl(config);
134
+ const token = await this.MintToken(config, baseUrl);
135
+ const auth = { Token: token, BaseUrl: baseUrl, Config: config };
136
+ this.authCache = auth;
137
+ return auth;
66
138
  }
67
- BuildHeaders(auth) {
68
- const imisAuth = auth;
139
+ /**
140
+ * Runs the password-grant token round-trip through OAuth2TokenManager. Tries the resolved
141
+ * token endpoint first, then the legacy `/asiScheduler/token` path on failure (older iMIS).
142
+ */
143
+ async MintToken(config, baseUrl) {
144
+ const primaryURL = this.ResolveTokenURL(config, baseUrl);
145
+ const req = this.BuildTokenRequest(config, primaryURL);
146
+ try {
147
+ const token = await this.tokenManager.GetAccessToken(req, 'password');
148
+ return token.AccessToken;
149
+ }
150
+ catch (primaryErr) {
151
+ // Legacy fallback only when no explicit override was given and the legacy path differs.
152
+ const legacyURL = `${baseUrl}${LEGACY_TOKEN_PATH}`;
153
+ if (config.TokenURL || legacyURL === primaryURL)
154
+ throw primaryErr;
155
+ this.tokenManager.Reset();
156
+ const token = await this.tokenManager.GetAccessToken({ ...req, TokenURL: legacyURL }, 'password');
157
+ return token.AccessToken;
158
+ }
159
+ }
160
+ /** Builds the OAuth2 password-grant request. iMIS does not require client credentials. */
161
+ BuildTokenRequest(config, tokenURL) {
69
162
  return {
70
- 'Authorization': `Bearer ${imisAuth.Token}`,
71
- 'Accept': 'application/json',
163
+ TokenURL: tokenURL,
164
+ ClientId: config.ClientId ?? '',
165
+ ClientSecret: config.ClientSecret ?? '',
166
+ Username: config.Username,
167
+ Password: config.Password,
168
+ TimeoutMs: config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
72
169
  };
73
170
  }
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;
171
+ /**
172
+ * Extracts the new record's external ID from a create response. iMIS returns the created
173
+ * object with a vendor-specific ID field (Id / PartyId / EventId / InvoiceId / …); the base
174
+ * scanner only knows the generic `id` family, so widen it for iMIS's `{Object}Id` convention.
175
+ * The empty-ID case still routes through BuildCreatedResult, which fails loudly.
176
+ */
177
+ ExtractIDFromResponse(response, idLocation) {
178
+ if ((!idLocation || idLocation === 'body') && response.Body && typeof response.Body === 'object') {
179
+ const b = response.Body;
180
+ const candidates = ['Id', 'ID', 'PartyId', 'UniformId', 'EventId', 'InvoiceId', 'PaymentId', 'id', 'externalID', 'ExternalID'];
181
+ for (const k of candidates) {
182
+ const v = b[k];
183
+ if (typeof v === 'string' || typeof v === 'number') {
184
+ const s = String(v).trim();
185
+ if (s.length > 0)
186
+ return s;
187
+ }
100
188
  }
101
- return this.BuildRESTResponse(response);
189
+ return undefined;
102
190
  }
103
- throw new Error(`iMIS API request failed after ${maxRetries} attempt(s): ${url}`);
191
+ return super.ExtractIDFromResponse(response, idLocation);
104
192
  }
193
+ /** Sends the OAuth2 bearer token on every request. */
194
+ BuildHeaders(auth) {
195
+ const token = auth.Token ?? auth.Token ?? '';
196
+ return {
197
+ 'Authorization': `Bearer ${token}`,
198
+ 'Accept': 'application/json',
199
+ 'Content-Type': 'application/json',
200
+ };
201
+ }
202
+ GetBaseURL(_companyIntegration, auth) {
203
+ return auth.BaseUrl;
204
+ }
205
+ /**
206
+ * Unwraps the iMIS PagedResult envelope to the Items collection. Handles the envelope,
207
+ * an explicit responseDataKey, a raw array, and a single-object detail response. The FULL
208
+ * source record passes through (no field filtering) so the framework's custom-column capture
209
+ * sees everything the source returned.
210
+ */
105
211
  NormalizeResponse(rawBody, responseDataKey) {
106
212
  if (rawBody == null)
107
213
  return [];
108
- // PagedResult envelope → Items
214
+ // PagedResult envelope → Items (ResponseDataKey is typically "Items")
109
215
  if (this.IsPagedResult(rawBody)) {
110
216
  return rawBody.Items ?? [];
111
217
  }
112
- // Explicit key provided (e.g., "Items")
113
218
  if (responseDataKey) {
114
219
  const record = rawBody;
115
220
  const data = record[responseDataKey];
@@ -118,19 +223,23 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
118
223
  }
119
224
  if (Array.isArray(rawBody))
120
225
  return rawBody;
121
- // Single-object response (e.g., GET /{Entity}/{id})
226
+ // Single-object detail response (GET /{Entity}/{id}).
122
227
  return [rawBody];
123
228
  }
124
- ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, pageSize) {
229
+ /**
230
+ * Derives Offset/Limit pagination state from the iMIS PagedResult envelope: advance Offset by
231
+ * the number of Items returned and continue while HasNext is true (terminate when HasNext is false).
232
+ */
233
+ ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, _pageSize) {
125
234
  if (paginationType === 'None')
126
235
  return { HasMore: false };
127
236
  const envelope = this.IsPagedResult(rawBody) ? rawBody : null;
128
237
  if (!envelope) {
129
- // Fallback: no PagedResult envelope → no more pages
238
+ // No PagedResult envelope → cannot advance → no more pages.
130
239
  return { HasMore: false };
131
240
  }
132
- const hasMore = envelope.HasNext ?? false;
133
241
  const returnedCount = envelope.Items?.length ?? 0;
242
+ const hasMore = (envelope.HasNext ?? false) && returnedCount > 0;
134
243
  const nextOffset = (envelope.Offset ?? currentOffset) + returnedCount;
135
244
  return {
136
245
  HasMore: hasMore,
@@ -139,15 +248,12 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
139
248
  TotalRecords: envelope.Count,
140
249
  };
141
250
  }
142
- GetBaseURL(_companyIntegration, auth) {
143
- return auth.BaseUrl;
144
- }
145
251
  /**
146
- * iMIS pagination uses `Offset` and `Limit` query parameters (capitalized).
147
- * Override the base class helper to match iMIS's param names.
252
+ * iMIS pagination uses capitalized `Offset` / `Limit` query parameters. Override the base
253
+ * helper to match iMIS's param names.
148
254
  */
149
255
  BuildPaginatedURL(basePath, obj, page, offset, cursor, effectivePageSize) {
150
- const pageSize = effectivePageSize ?? obj.DefaultPageSize ?? 100;
256
+ const pageSize = effectivePageSize ?? obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
151
257
  const separator = basePath.includes('?') ? '&' : '?';
152
258
  switch (obj.PaginationType) {
153
259
  case 'PageNumber':
@@ -163,156 +269,152 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
163
269
  }
164
270
  }
165
271
  /**
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.
272
+ * Injects the incremental watermark filter when a sync is active. The param NAME is fully
273
+ * METADATA-DRIVEN: it comes from the IO's `IncrementalWatermarkField` and is emitted only when
274
+ * SupportsIncrementalSync=true AND a watermark value is in context. iMIS accepts operator-prefixed
275
+ * filter values (`gt:`) on list endpoints.
168
276
  */
169
277
  AppendDefaultQueryParams(url, obj) {
170
278
  let result = super.AppendDefaultQueryParams(url, obj);
171
- if (this.currentWatermark && obj.SupportsIncrementalSync) {
279
+ const watermarkField = obj.IncrementalWatermarkField;
280
+ if (obj.SupportsIncrementalSync && watermarkField && this.currentWatermark) {
172
281
  const separator = result.includes('?') ? '&' : '?';
173
- const param = `UpdatedOn=${encodeURIComponent('gt:' + this.currentWatermark)}`;
282
+ const param = `${encodeURIComponent(watermarkField)}=${encodeURIComponent('gt:' + this.currentWatermark)}`;
174
283
  result = `${result}${separator}${param}`;
175
284
  }
176
285
  return result;
177
286
  }
178
- // ── Public API: Discovery, FetchChanges, CRUD ───────────────────
287
+ /** Executes an HTTP request with client-side throttling + retry for 401/429/5xx. */
288
+ async MakeHTTPRequest(auth, url, method, headers, body) {
289
+ const imisAuth = auth;
290
+ const maxRetries = imisAuth.Config.MaxRetries ?? DEFAULT_MAX_RETRIES;
291
+ const timeoutMs = imisAuth.Config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
292
+ const minInterval = imisAuth.Config.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
293
+ const effectiveHeaders = { ...headers };
294
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
295
+ await this.ThrottleIfNeeded(minInterval);
296
+ const fetchOptions = {
297
+ method,
298
+ headers: effectiveHeaders,
299
+ signal: AbortSignal.timeout(timeoutMs),
300
+ };
301
+ if (body !== undefined && method !== 'GET') {
302
+ fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
303
+ }
304
+ let response;
305
+ try {
306
+ response = await fetch(url, fetchOptions);
307
+ }
308
+ catch (err) {
309
+ if (attempt < maxRetries && this.IsTransientNetworkError(err)) {
310
+ await this.Sleep(this.BackoffDelay(attempt));
311
+ continue;
312
+ }
313
+ throw err;
314
+ }
315
+ this.lastRequestTime = Date.now();
316
+ // Token expired mid-run — force a fresh mint and retry once with the new bearer.
317
+ if (response.status === 401 && attempt < maxRetries) {
318
+ this.tokenManager.Reset();
319
+ this.authCache = null;
320
+ const fresh = await this.MintToken(imisAuth.Config, imisAuth.BaseUrl);
321
+ imisAuth.Token = fresh;
322
+ this.authCache = imisAuth;
323
+ effectiveHeaders['Authorization'] = `Bearer ${fresh}`;
324
+ continue;
325
+ }
326
+ if ((response.status === 429 || response.status >= 500) && attempt < maxRetries) {
327
+ console.warn(`[iMIS] HTTP ${response.status} from ${url} — backing off`);
328
+ await this.Sleep(this.RetryAfterMs(response) ?? this.BackoffDelay(attempt));
329
+ continue;
330
+ }
331
+ return this.BuildRESTResponse(response);
332
+ }
333
+ throw new Error(`iMIS request failed after ${maxRetries + 1} attempts: ${url}`);
334
+ }
335
+ // ─── TestConnection ──────────────────────────────────────────────
179
336
  /**
180
- * Test the connection by authenticating and hitting the /Party list endpoint
181
- * with a minimal page size. Confirms both auth + data-plane connectivity.
337
+ * Tests connectivity by minting a password-grant token and listing one Party record. A 2xx
338
+ * confirms the OAuth2 credentials + base URL are valid against the live API.
182
339
  */
183
340
  async TestConnection(companyIntegration, contextUser) {
184
341
  try {
185
- const auth = await this.GetAuth(companyIntegration, contextUser, true);
186
- const url = `${auth.BaseUrl}/Party?Limit=1`;
342
+ const auth = (await this.Authenticate(companyIntegration, contextUser));
187
343
  const headers = this.BuildHeaders(auth);
344
+ const url = `${auth.BaseUrl}/Party?Limit=1`;
188
345
  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
- };
346
+ if (response.Status < 200 || response.Status >= 300) {
347
+ return { Success: false, Message: `iMIS returned HTTP ${response.Status} from ${url}` };
195
348
  }
196
- return { Success: false, Message: `iMIS responded HTTP ${response.Status}` };
349
+ return {
350
+ Success: true,
351
+ Message: `Connected to iMIS EMS at ${auth.BaseUrl}`,
352
+ ServerVersion: this.ExtractServerVersion(response.Headers),
353
+ };
197
354
  }
198
355
  catch (err) {
199
356
  const message = err instanceof Error ? err.message : String(err);
200
357
  return { Success: false, Message: `Connection failed: ${message}` };
201
358
  }
202
359
  }
360
+ // ─── Discovery (metadata-driven baseline + runtime mechanism) ────
203
361
  /**
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.
362
+ * Discovers objects: the Declared core baseline from the IntegrationEngineBase cache, ADDITIVELY
363
+ * extended at runtime via the auth-gated /EntityDefinition endpoint (the tenant's customer
364
+ * Business-Object catalog). The catalog is NEVER baked into code — DiscoverObjects expresses only
365
+ * the discovery MECHANISM. Failures degrade gracefully to the Declared baseline.
208
366
  */
209
367
  async DiscoverObjects(companyIntegration, contextUser) {
210
- const staticObjects = await super.DiscoverObjects(companyIntegration, contextUser);
211
- const staticNames = new Set(staticObjects.map(o => o.Name.toLowerCase()));
368
+ const declared = await super.DiscoverObjects(companyIntegration, contextUser);
369
+ const declaredNames = new Set(declared.map(o => o.Name.toLowerCase()));
212
370
  try {
213
- const customObjects = await this.DiscoverBusinessObjects(companyIntegration, contextUser);
214
- const newCustom = customObjects.filter(co => !staticNames.has(co.Name.toLowerCase()));
215
- return [...staticObjects, ...newCustom];
371
+ const discovered = await this.DiscoverEntityDefinitions(companyIntegration, contextUser);
372
+ const additive = discovered.filter(d => !declaredNames.has(d.Name.toLowerCase()));
373
+ return [...declared, ...additive];
216
374
  }
217
375
  catch (err) {
218
376
  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;
377
+ console.warn(`[iMIS] EntityDefinition discovery failed, returning declared objects only: ${message}`);
378
+ return declared;
221
379
  }
222
380
  }
223
381
  /**
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).
382
+ * Discovers fields: the Declared baseline from the cache, ADDITIVELY extended at runtime via the
383
+ * auth-gated /{entity}/metadata endpoint (the tenant's PropertyDefinitions). The field set is
384
+ * NEVER baked in code. Failures degrade gracefully to the Declared baseline.
229
385
  */
230
386
  async DiscoverFields(companyIntegration, objectName, contextUser) {
231
- const staticFields = await super.DiscoverFields(companyIntegration, objectName, contextUser);
387
+ const declared = await super.DiscoverFields(companyIntegration, objectName, contextUser);
232
388
  try {
233
- const liveFields = await this.DiscoverFieldsFromLiveAPI(companyIntegration, objectName, contextUser);
234
- return this.MergeFieldSchemas(staticFields, liveFields);
389
+ const live = await this.DiscoverFieldsFromLiveAPI(companyIntegration, objectName, contextUser);
390
+ return this.MergeFieldSchemas(declared, live);
235
391
  }
236
392
  catch (err) {
237
393
  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;
394
+ console.warn(`[iMIS] Live field discovery failed for "${objectName}", returning declared fields: ${message}`);
395
+ return declared;
240
396
  }
241
397
  }
398
+ // ─── FetchChanges override (watermark wiring) ────────────────────
242
399
  /**
243
- * Override FetchChanges to stash the current watermark so AppendDefaultQueryParams
244
- * can inject the UpdatedOn filter on list requests.
400
+ * Sets the watermark context the URL builder needs, delegates the walk to the base (which handles
401
+ * pagination + nested template-var paths), then advances the watermark from the returned records
402
+ * on the FINAL batch only (partial-failure-safe — a mid-stream failure leaves the watermark unchanged).
245
403
  */
246
404
  async FetchChanges(ctx) {
247
- this.currentWatermark = ctx.WatermarkValue;
405
+ this.currentWatermark = ctx.WatermarkValue ?? undefined;
248
406
  try {
249
407
  const result = await super.FetchChanges(ctx);
250
- result.NewWatermarkValue = this.ComputeNewWatermark(result.Records, ctx.WatermarkValue);
408
+ const isFinal = !result.HasMore;
409
+ result.NewWatermarkValue = isFinal
410
+ ? this.ComputeNewWatermark(result.Records, ctx.WatermarkValue)
411
+ : undefined;
251
412
  return result;
252
413
  }
253
414
  finally {
254
- this.currentWatermark = null;
415
+ this.currentWatermark = undefined;
255
416
  }
256
417
  }
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 this.BuildCreatedResult(this.ExtractIdFromRecord(body), response.Status, ctx.ObjectName);
268
- }
269
- return this.BuildCRUDError(response, 'CreateRecord', ctx.ObjectName);
270
- }
271
- async UpdateRecord(ctx) {
272
- const companyIntegration = ctx.CompanyIntegration;
273
- const contextUser = ctx.ContextUser;
274
- const auth = await this.GetAuth(companyIntegration, contextUser);
275
- const objectPath = this.NormalizeObjectPath(ctx.ObjectName);
276
- // iMIS PUT is a full replace — do a read-modify-write merge of Attributes
277
- // into the current record so callers can supply partial updates.
278
- const current = await this.FetchSingleRecord(auth, objectPath, ctx.ExternalID);
279
- if (!current) {
280
- return { Success: false, ErrorMessage: `Record ${ctx.ExternalID} not found`, StatusCode: 404 };
281
- }
282
- const merged = { ...current, ...ctx.Attributes };
283
- const url = `${auth.BaseUrl}/${objectPath}/${encodeURIComponent(ctx.ExternalID)}`;
284
- const headers = this.BuildHeaders(auth);
285
- const response = await this.MakeHTTPRequest(auth, url, 'PUT', headers, merged);
286
- if (response.Status >= 200 && response.Status < 300) {
287
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
288
- }
289
- return this.BuildCRUDError(response, 'UpdateRecord', ctx.ObjectName);
290
- }
291
- async DeleteRecord(ctx) {
292
- const companyIntegration = ctx.CompanyIntegration;
293
- const contextUser = ctx.ContextUser;
294
- const auth = await this.GetAuth(companyIntegration, contextUser);
295
- const url = `${auth.BaseUrl}/${this.NormalizeObjectPath(ctx.ObjectName)}/${encodeURIComponent(ctx.ExternalID)}`;
296
- const headers = this.BuildHeaders(auth);
297
- const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
298
- if (response.Status >= 200 && response.Status < 300) {
299
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
300
- }
301
- return this.BuildCRUDError(response, 'DeleteRecord', ctx.ObjectName);
302
- }
303
- async GetRecord(ctx) {
304
- const companyIntegration = ctx.CompanyIntegration;
305
- const contextUser = ctx.ContextUser;
306
- const auth = await this.GetAuth(companyIntegration, contextUser);
307
- const record = await this.FetchSingleRecord(auth, this.NormalizeObjectPath(ctx.ObjectName), ctx.ExternalID);
308
- if (!record)
309
- return null;
310
- return {
311
- ExternalID: ctx.ExternalID,
312
- ObjectType: ctx.ObjectName,
313
- Fields: record,
314
- };
315
- }
316
418
  // ── Default configuration / field mappings ──────────────────────
317
419
  GetDefaultConfiguration() {
318
420
  return {
@@ -344,152 +446,27 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
344
446
  // ─────────────────────────────────────────────────────────────────
345
447
  // Private helpers
346
448
  // ─────────────────────────────────────────────────────────────────
347
- // ── Auth helpers ────────────────────────────────────────────────
348
- /**
349
- * Returns a valid auth context, refreshing the access token as needed.
350
- * Accepts either (companyIntegration, contextUser) for the first call
351
- * or a pre-parsed config for silent refresh from MakeHTTPRequest.
352
- */
353
- async GetAuth(source, contextUser, forceRefresh = false) {
354
- if (!forceRefresh && this.authState && this.IsTokenValid(this.authState)) {
355
- return this.authState;
356
- }
357
- const config = this.IsConfig(source)
358
- ? source
359
- : await this.ParseConfig(source, contextUser);
360
- this.authState = await this.AcquireToken(config);
361
- return this.authState;
362
- }
363
- IsConfig(source) {
364
- return source.BaseUrl !== undefined
365
- && source.Username !== undefined;
366
- }
367
- IsTokenValid(auth) {
368
- return auth.ExpiresAt.getTime() > Date.now() + TOKEN_REFRESH_BUFFER_MS;
369
- }
370
- async AcquireToken(config) {
371
- const baseUrl = this.StripTrailingSlash(config.BaseUrl);
372
- const tokenUrl = this.ResolveTokenEndpoint(baseUrl);
373
- const body = this.BuildTokenRequestBody(config);
374
- const response = await fetch(tokenUrl, {
375
- method: 'POST',
376
- headers: {
377
- 'Accept': 'application/json',
378
- 'Content-Type': 'application/x-www-form-urlencoded',
379
- },
380
- body,
381
- });
382
- if (!response.ok) {
383
- const text = await response.text();
384
- throw new Error(`iMIS token request failed (${response.status}): ${text.slice(0, 300)}`);
385
- }
386
- const payload = await response.json();
387
- const expiresAt = new Date(Date.now() + (payload.expires_in ?? 3600) * 1000);
388
- if (payload.refresh_token) {
389
- config.RefreshToken = payload.refresh_token;
390
- }
391
- return {
392
- Token: payload.access_token,
393
- ExpiresAt: expiresAt,
394
- Config: config,
395
- BaseUrl: baseUrl,
396
- };
397
- }
398
- /**
399
- * iMIS issues tokens at the scheduler path, not under /api/. Strip /api[/]
400
- * from the BaseUrl when constructing the token endpoint.
401
- */
402
- ResolveTokenEndpoint(baseUrl) {
403
- const trimmed = baseUrl.replace(/\/api\/?$/i, '');
404
- // Vendor-canonical path is lowercase '/token/' per developer.imis.com docs.
405
- // IIS is normally case-insensitive on URL paths so '/Token' usually works,
406
- // but a few customer reverse-proxies enforce case strictness — match docs.
407
- return `${trimmed}/token/`;
408
- }
409
- BuildTokenRequestBody(config) {
410
- const params = new URLSearchParams();
411
- if (config.ClientId && config.ClientSecret && config.RefreshToken) {
412
- params.set('grant_type', 'refresh_token');
413
- params.set('refresh_token', config.RefreshToken);
414
- params.set('client_id', config.ClientId);
415
- params.set('client_secret', config.ClientSecret);
416
- return params.toString();
417
- }
418
- params.set('grant_type', 'password');
419
- params.set('username', config.Username);
420
- params.set('password', config.Password);
421
- return params.toString();
422
- }
423
- // ── Configuration parsing ───────────────────────────────────────
424
- async ParseConfig(companyIntegration, contextUser) {
425
- if (companyIntegration.CredentialID) {
426
- const config = await this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser);
427
- if (config)
428
- return config;
429
- }
430
- if (companyIntegration.Configuration) {
431
- const parsed = JSON.parse(companyIntegration.Configuration);
432
- return this.ExtractConfig(parsed);
433
- }
434
- throw new Error('iMIS connector requires either CredentialID or Configuration JSON');
435
- }
436
- async ParseConfigFromCredential(credentialID, contextUser, provider) {
437
- const md = provider ?? new Metadata();
438
- const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
439
- const loaded = await credential.Load(credentialID);
440
- if (!loaded || !credential.Values)
441
- return null;
442
- const values = JSON.parse(credential.Values);
443
- return this.ExtractConfig(values);
444
- }
445
- ExtractConfig(values) {
446
- const get = (...keys) => {
447
- for (const key of keys) {
448
- const hit = Object.entries(values).find(([k]) => k.toLowerCase() === key.toLowerCase());
449
- if (hit)
450
- return String(hit[1] ?? '');
451
- }
452
- return undefined;
453
- };
454
- const baseUrl = get('BaseUrl', 'baseUrl', 'base_url');
455
- const username = get('Username', 'username', 'user');
456
- const password = get('Password', 'password', 'pass');
457
- if (!baseUrl)
458
- throw new Error('iMIS configuration missing required field: BaseUrl');
459
- if (!username)
460
- throw new Error('iMIS configuration missing required field: Username');
461
- if (!password)
462
- throw new Error('iMIS configuration missing required field: Password');
463
- return {
464
- BaseUrl: this.StripTrailingSlash(baseUrl),
465
- Username: username,
466
- Password: password,
467
- ClientId: get('ClientId', 'clientId', 'client_id'),
468
- ClientSecret: get('ClientSecret', 'clientSecret', 'client_secret'),
469
- RefreshToken: get('RefreshToken', 'refreshToken', 'refresh_token'),
470
- };
471
- }
472
- // ── Runtime discovery (BOs + field metadata) ────────────────────
473
- async DiscoverBusinessObjects(companyIntegration, contextUser) {
474
- const auth = await this.GetAuth(companyIntegration, contextUser);
475
- const url = `${auth.BaseUrl}/BOEntityDefinition?Limit=500`;
449
+ // ── Runtime discovery (Business Objects + field metadata) ───────
450
+ async DiscoverEntityDefinitions(companyIntegration, contextUser) {
451
+ const auth = (await this.Authenticate(companyIntegration, contextUser));
452
+ const url = `${auth.BaseUrl}/EntityDefinition?Limit=500`;
476
453
  const headers = this.BuildHeaders(auth);
477
454
  const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
478
455
  if (response.Status < 200 || response.Status >= 300)
479
456
  return [];
480
457
  const records = this.NormalizeResponse(response.Body, 'Items');
481
458
  return records
482
- .filter(bo => !!bo.Name)
483
- .map(bo => ({
484
- Name: String(bo.Name),
485
- Label: bo.DisplayName ?? String(bo.Name),
486
- Description: bo.Description ?? `Customer-defined Business Object (runtime-discovered)`,
459
+ .filter(d => !!d.Name)
460
+ .map(d => ({
461
+ Name: String(d.Name),
462
+ Label: d.DisplayName ?? String(d.Name),
463
+ Description: d.Description ?? 'Tenant-defined Business Object (runtime-discovered)',
487
464
  SupportsIncrementalSync: true,
488
- SupportsWrite: true,
465
+ SupportsWrite: false,
489
466
  }));
490
467
  }
491
468
  async DiscoverFieldsFromLiveAPI(companyIntegration, objectName, contextUser) {
492
- const auth = await this.GetAuth(companyIntegration, contextUser);
469
+ const auth = (await this.Authenticate(companyIntegration, contextUser));
493
470
  const path = this.ResolveObjectPath(companyIntegration.IntegrationID, objectName);
494
471
  const url = `${auth.BaseUrl}${path}/metadata`;
495
472
  const headers = this.BuildHeaders(auth);
@@ -511,25 +488,25 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
511
488
  Description: prop.Description,
512
489
  DataType: genericType,
513
490
  IsRequired: !!prop.IsRequired,
491
+ // Provable-only: the source explicitly distinguishes PK; only set PK when stated.
492
+ IsPrimaryKey: prop.IsPrimaryKey === true ? true : undefined,
514
493
  IsUniqueKey: !!prop.IsPrimaryKey,
515
494
  IsReadOnly: !!prop.IsReadOnly,
495
+ MaxLength: prop.MaxLength ?? undefined,
516
496
  };
517
497
  }
518
498
  /**
519
- * Merge live-discovered fields with static metadata. Live-only fields are
520
- * treated as custom — surfaced with whatever properties the live API reports.
521
- * Static fields override live fields on matching names (static metadata
522
- * reflects the curated MJ schema).
499
+ * Merge live-discovered fields with the Declared baseline. Live-only fields are appended;
500
+ * Declared fields win on matching names (the curated MJ schema is authoritative for the baseline).
523
501
  */
524
- MergeFieldSchemas(staticFields, liveFields) {
525
- const staticNames = new Set(staticFields.map(f => f.Name.toLowerCase()));
526
- const liveOnly = liveFields.filter(f => !staticNames.has(f.Name.toLowerCase()));
527
- return [...staticFields, ...liveOnly];
502
+ MergeFieldSchemas(declared, live) {
503
+ const declaredNames = new Set(declared.map(f => f.Name.toLowerCase()));
504
+ const liveOnly = live.filter(f => !declaredNames.has(f.Name.toLowerCase()));
505
+ return [...declared, ...liveOnly];
528
506
  }
529
507
  /**
530
- * Resolve an object name to its API path. Prefers the configured APIPath
531
- * from IntegrationObject metadata; falls back to `/{ObjectName}` for
532
- * runtime-discovered Business Objects.
508
+ * Resolve an object name to its API path. Prefers the configured APIPath from IntegrationObject
509
+ * metadata; falls back to `/{ObjectName}` for runtime-discovered Business Objects.
533
510
  */
534
511
  ResolveObjectPath(integrationID, objectName) {
535
512
  try {
@@ -539,34 +516,96 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
539
516
  }
540
517
  }
541
518
  catch {
542
- // Not in static metadata — this is a runtime-discovered Business Object.
519
+ // Not in Declared metadata — a runtime-discovered Business Object.
543
520
  }
544
521
  return `/${objectName}`;
545
522
  }
546
- /** Derive the URL path segment used by CRUD operations (no leading slash). */
547
- NormalizeObjectPath(objectName) {
548
- return objectName.startsWith('/') ? objectName.slice(1) : objectName;
523
+ // ── Config parsing ──────────────────────────────────────────────
524
+ /**
525
+ * Parses the connection config, preferring the attached MJ Credential over the raw Configuration
526
+ * JSON. Credential bytes are resolved at runtime — never at build.
527
+ */
528
+ async ParseConfig(companyIntegration, contextUser) {
529
+ if (companyIntegration.CredentialID) {
530
+ return this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser);
531
+ }
532
+ if (companyIntegration.Configuration) {
533
+ return this.ValidateConfig(JSON.parse(companyIntegration.Configuration));
534
+ }
535
+ throw new Error('iMIS connector requires either CredentialID or Configuration JSON');
549
536
  }
550
- // ── Record / watermark helpers ──────────────────────────────────
551
- async FetchSingleRecord(auth, objectPath, id) {
552
- const url = `${auth.BaseUrl}/${objectPath}/${encodeURIComponent(id)}`;
553
- const headers = this.BuildHeaders(auth);
554
- const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
555
- if (response.Status < 200 || response.Status >= 300)
556
- return null;
557
- if (response.Body == null || typeof response.Body !== 'object')
558
- return null;
559
- return response.Body;
560
- }
561
- ExtractIdFromRecord(body) {
562
- const candidates = ['Id', 'ID', 'PartyId', 'UniformId', 'EventId', 'InvoiceId', 'PaymentId'];
563
- for (const key of candidates) {
564
- const value = body[key];
565
- if (value !== undefined && value !== null)
566
- return String(value);
537
+ /** Loads the config from the MJ: Credentials entity Values JSON. */
538
+ async ParseConfigFromCredential(credentialID, contextUser, provider) {
539
+ const md = provider ?? new Metadata();
540
+ const cred = await md.GetEntityObject('MJ: Credentials', contextUser);
541
+ const loaded = await cred.Load(credentialID);
542
+ if (!loaded || !cred.Values) {
543
+ throw new Error('iMIS credential could not be loaded or has no Values JSON');
567
544
  }
568
- return '';
545
+ return this.ValidateConfig(JSON.parse(cred.Values));
546
+ }
547
+ /** Validates the parsed config + applies defaults. Field names are case-insensitive. */
548
+ ValidateConfig(raw) {
549
+ if (!raw || typeof raw !== 'object') {
550
+ throw new Error('iMIS configuration is not a valid object');
551
+ }
552
+ const obj = raw;
553
+ const getStr = (...keys) => {
554
+ for (const key of keys) {
555
+ const lower = key.toLowerCase();
556
+ for (const [k, v] of Object.entries(obj)) {
557
+ if (k.toLowerCase() === lower && typeof v === 'string' && v.length > 0)
558
+ return v;
559
+ }
560
+ }
561
+ return undefined;
562
+ };
563
+ const getNum = (...keys) => {
564
+ for (const key of keys) {
565
+ const lower = key.toLowerCase();
566
+ for (const [k, v] of Object.entries(obj)) {
567
+ if (k.toLowerCase() === lower && typeof v === 'number')
568
+ return v;
569
+ }
570
+ }
571
+ return undefined;
572
+ };
573
+ const baseURL = getStr('baseurl', 'base_url');
574
+ if (!baseURL)
575
+ throw new Error('iMIS configuration missing required field: BaseURL');
576
+ const username = getStr('username', 'user');
577
+ const password = getStr('password', 'pass');
578
+ if (!username)
579
+ throw new Error('iMIS configuration missing required field: Username');
580
+ if (!password)
581
+ throw new Error('iMIS configuration missing required field: Password');
582
+ return {
583
+ BaseURL: baseURL,
584
+ Username: username,
585
+ Password: password,
586
+ TokenURL: getStr('tokenurl', 'token_url'),
587
+ ClientId: getStr('clientid', 'client_id'),
588
+ ClientSecret: getStr('clientsecret', 'client_secret'),
589
+ MaxRetries: getNum('maxretries') ?? DEFAULT_MAX_RETRIES,
590
+ RequestTimeoutMs: getNum('requesttimeoutms') ?? DEFAULT_REQUEST_TIMEOUT_MS,
591
+ MinRequestIntervalMs: getNum('minrequestintervalms') ?? DEFAULT_MIN_REQUEST_INTERVAL_MS,
592
+ };
593
+ }
594
+ /** Resolves the API base URL from the credential's BaseURL (never a hardcoded host). */
595
+ ResolveBaseUrl(config) {
596
+ return config.BaseURL.replace(/\/+$/, '');
597
+ }
598
+ /**
599
+ * Resolves the token endpoint: credential TokenURL override, else `{base}/token`. iMIS issues
600
+ * tokens at the host root (not under /api/), so a trailing /api segment is stripped.
601
+ */
602
+ ResolveTokenURL(config, baseUrl) {
603
+ if (config.TokenURL && config.TokenURL.length > 0)
604
+ return config.TokenURL;
605
+ const root = baseUrl.replace(/\/api$/i, '');
606
+ return `${root}${DEFAULT_TOKEN_PATH}`;
569
607
  }
608
+ // ── Watermark helper ────────────────────────────────────────────
570
609
  ComputeNewWatermark(records, current) {
571
610
  const candidateKeys = ['UpdatedOn', 'UpdateDate', 'ModifiedOn', 'LastUpdatedAt'];
572
611
  let latest;
@@ -584,99 +623,73 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
584
623
  return latest;
585
624
  }
586
625
  // ── HTTP helpers ────────────────────────────────────────────────
587
- async ExecuteFetch(url, method, headers, body, timeoutMs) {
588
- const controller = new AbortController();
589
- const timer = setTimeout(() => controller.abort(), timeoutMs);
590
- try {
591
- return await fetch(url, {
592
- method,
593
- headers,
594
- body: body !== undefined ? JSON.stringify(body) : undefined,
595
- signal: controller.signal,
596
- });
597
- }
598
- finally {
599
- clearTimeout(timer);
600
- }
626
+ /** Throttle to respect the configured minimum request interval. */
627
+ async ThrottleIfNeeded(minIntervalMs) {
628
+ const elapsed = Date.now() - this.lastRequestTime;
629
+ if (elapsed < minIntervalMs)
630
+ await this.Sleep(minIntervalMs - elapsed);
631
+ }
632
+ /** Parses a Retry-After header (seconds or http-date) into ms, if present. */
633
+ RetryAfterMs(response) {
634
+ const header = response.headers.get('retry-after');
635
+ if (!header)
636
+ return undefined;
637
+ const asSeconds = Number(header);
638
+ if (!isNaN(asSeconds))
639
+ return Math.max(0, asSeconds * 1_000);
640
+ const asDate = new Date(header).getTime();
641
+ if (!isNaN(asDate))
642
+ return Math.max(0, asDate - Date.now());
643
+ return undefined;
644
+ }
645
+ /** Exponential backoff delay for retry attempts (capped at 15s). */
646
+ BackoffDelay(attempt) {
647
+ return Math.min(1_000 * Math.pow(2, attempt), 15_000);
648
+ }
649
+ /** Checks whether an error is transient (network/timeout). */
650
+ IsTransientNetworkError(err) {
651
+ if (!(err instanceof Error))
652
+ return false;
653
+ const msg = err.message.toLowerCase();
654
+ return msg.includes('timeout') || msg.includes('abort') ||
655
+ msg.includes('econnreset') || msg.includes('econnrefused') ||
656
+ msg.includes('fetch failed');
601
657
  }
658
+ /** Builds the normalized RESTResponse from a fetch Response. */
602
659
  async BuildRESTResponse(response) {
603
660
  const headers = {};
604
661
  response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
605
- const contentType = headers['content-type'] ?? '';
606
- let bodyValue;
607
- if (contentType.includes('application/json')) {
608
- bodyValue = await this.ParseJsonSafely(response);
609
- }
610
- else {
611
- bodyValue = await response.text();
612
- }
613
- return { Status: response.status, Body: bodyValue, Headers: headers };
614
- }
615
- async ParseJsonSafely(response) {
616
- try {
617
- return await response.json();
618
- }
619
- catch {
620
- return null;
621
- }
622
- }
623
- ShouldRetry(status) {
624
- return status === 429 || status === 502 || status === 503 || status === 504;
625
- }
626
- ComputeRetryDelay(response, attempt) {
627
- const retryAfter = response.headers.get('retry-after');
628
- if (retryAfter) {
629
- const seconds = Number.parseInt(retryAfter, 10);
630
- if (!Number.isNaN(seconds) && seconds > 0)
631
- return seconds * 1000;
662
+ const text = await response.text();
663
+ let body = null;
664
+ if (text.length > 0) {
665
+ try {
666
+ body = JSON.parse(text);
667
+ }
668
+ catch {
669
+ body = text;
670
+ }
632
671
  }
633
- return Math.min(Math.pow(2, attempt) * 1000, 15_000);
634
- }
635
- async Throttle(minIntervalMs) {
636
- const elapsed = Date.now() - this.lastRequestTime;
637
- if (elapsed < minIntervalMs)
638
- await this.Sleep(minIntervalMs - elapsed);
639
- }
640
- Sleep(ms) {
641
- return new Promise(resolve => setTimeout(resolve, ms));
672
+ return { Status: response.status, Body: body, Headers: headers };
642
673
  }
643
674
  ExtractServerVersion(headers) {
644
675
  return headers['x-imis-version'] ?? headers['server'] ?? 'iMIS REST API';
645
676
  }
677
+ /** True when a body is the iMIS PagedResult envelope (Items[] + Offset/Limit/HasNext). */
646
678
  IsPagedResult(body) {
647
679
  if (body == null || typeof body !== 'object' || Array.isArray(body))
648
680
  return false;
649
681
  const record = body;
650
682
  return Array.isArray(record['Items']) && ('Offset' in record || 'Limit' in record || 'HasNext' in record);
651
683
  }
652
- StripTrailingSlash(url) {
653
- return url.endsWith('/') ? url.slice(0, -1) : url;
654
- }
655
- BuildCRUDError(response, operation, objectName) {
656
- const body = response.Body;
657
- let detail;
658
- if (typeof body === 'string') {
659
- detail = body.slice(0, 500);
660
- }
661
- else if (body && typeof body === 'object') {
662
- const asRecord = body;
663
- const msg = asRecord['Message'] ?? asRecord['error_description'] ?? asRecord['error'];
664
- detail = msg !== undefined ? String(msg) : JSON.stringify(body).slice(0, 500);
665
- }
666
- else {
667
- detail = `HTTP ${response.Status}`;
668
- }
669
- return {
670
- Success: false,
671
- ErrorMessage: `iMIS ${operation} failed on ${objectName}: ${detail}`,
672
- StatusCode: response.Status,
673
- };
684
+ /** Promise-wrapped setTimeout. */
685
+ Sleep(ms) {
686
+ return new Promise(resolve => setTimeout(resolve, ms));
674
687
  }
675
688
  };
676
689
  IMISConnector = __decorate([
677
690
  RegisterClass(BaseIntegrationConnector, 'IMISConnector')
678
691
  ], IMISConnector);
679
692
  export { IMISConnector };
680
- /** Tree-shaking prevention — import and call from module entry point */
693
+ /** Tree-shaking prevention — import and call from the package entry point. */
681
694
  export function LoadIMISConnector() { }
682
695
  //# sourceMappingURL=IMISConnector.js.map