@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
@@ -0,0 +1,893 @@
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
+ /**
8
+ * OpenWaterConnector — REST integration connector for OpenWater
9
+ * (https://www.getopenwater.com), an awards / grants / abstracts / fellowship
10
+ * submission-and-review platform. The connector targets OpenWater's v2 REST API.
11
+ *
12
+ * Auth — DUAL custom headers on EVERY request (no token exchange; the
13
+ * Account/Authenticate endpoint is out of scope):
14
+ * - X-ClientKey (config: ClientKey)
15
+ * - X-ApiKey (config secret: ApiKey)
16
+ * - X-OrganizationCode (config: OrganizationCode, OPTIONAL)
17
+ *
18
+ * Pagination — PageNumber (pageIndex / pageSize); advance pageIndex until a
19
+ * short/empty page.
20
+ *
21
+ * Incremental — per-IO IncrementalWatermarkField (lastModifiedSinceUtc /
22
+ * createdSinceUtc / deletedSinceUtc / lastModifiedUtc / mostRecentTransactionSinceUtc)
23
+ * formatted into the request query.
24
+ *
25
+ * Nested objects — OpenWater's object universe is larger than its directly-
26
+ * queryable doors. Each nested IO carries an AccessPath in its IntegrationObject
27
+ * Configuration { door, doorPath, parentParamName, nestingSegments[], entryPath,
28
+ * parentParamIn, extractionMode, alternativePaths[] }. FetchChanges WALKS that path:
29
+ * it queries the door, descends to the leaf parent IDs (e.g. Program -> rounds[] ->
30
+ * roundId), then calls the entry path once per parent — injecting the parent id either
31
+ * into the path template ({programId}/{fundId}) or as a query param (roundId-gated
32
+ * JudgeAssignments/Recusals, which 400 without it). An `embedded-array` AccessPath
33
+ * (Rounds) emits records directly from the door payload with no second call.
34
+ *
35
+ * Discovery — credential-free: DiscoverObjects/DiscoverFields/IntrospectSchema use the
36
+ * BaseRESTIntegrationConnector implementations, which read the Declared IO/IOF metadata
37
+ * from the IntegrationEngineBase cache. No catalog is baked into this file.
38
+ *
39
+ * Write — generic per-operation CRUD (Create/Update/Delete read from the IO's
40
+ * CreateAPIPath/Method/BodyShape/IDLocation, Update*, Delete* columns) for the FLAT-body
41
+ * write IOs (Application, JudgeTeam, User, Evaluation, ScheduleDay/Item/Room). THREE IOs
42
+ * declare CreateBodyShape='literal' because their create-request schema needs fields absent
43
+ * from the synced list-model, so CreateRecord is overridden for them (each still routes
44
+ * through BuildCreatedResult; each IO's Configuration.LiteralCreateReason documents the
45
+ * mapping):
46
+ * - Session → POST /v2/Sessions {programId, typeId, name, ...}; typeId resolved
47
+ * from the synced typeName via the SessionType IO (or a typeId input),
48
+ * per Models.Session.CreateRequest.
49
+ * - JudgeAssignment → POST /v2/JudgeAssignments/Round {judgeUserId, roundId}; judgeUserId
50
+ * <- record.userId, roundId from the create attributes' round context,
51
+ * per Models.JudgeAssignment.AssignJudgeToRoundRequest. CreateIDLocation
52
+ * /DeleteIDLocation = 'n/a' (no body id) → synthetic composite identity
53
+ * roundId|judgeUserId. Delete re-uses the same /Round endpoint with the
54
+ * pair as query params (DELETE has no path id).
55
+ * - ScheduleTimeSlot→ POST/PATCH supply scheduleDayIds (from the read-side availableOnlyInDayIds),
56
+ * per Models.ScheduleTimeSlot.Create/UpdateRequest. ScheduleTimeSlot also
57
+ * declares UpdateBodyShape='literal', so UpdateRecord is overridden too.
58
+ * All other write IOs (incl. the lifecycle PATCHes Forwarding/WinnerAssignment/Status, which
59
+ * are NOT in the frozen contract) use the generic per-operation path unchanged. These literal
60
+ * write paths are untestable credential-free → RequiresLiveVerification (T10).
61
+ */
62
+ import { RegisterClass } from '@memberjunction/global';
63
+ import { Metadata } from '@memberjunction/core';
64
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
65
+ // ─── Constants ──────────────────────────────────────────────────────────
66
+ const DEFAULT_BASE_URL = 'https://api.getopenwater.com';
67
+ const DEFAULT_PAGE_SIZE = 100;
68
+ const DEFAULT_MAX_RETRIES = 3;
69
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
70
+ /** OpenWater is a modest-throughput enterprise API — keep a conservative sustained rate. */
71
+ const RATE_LIMIT_TOKENS_PER_SEC = 3;
72
+ // ─── Connector ──────────────────────────────────────────────────────────
73
+ let OpenWaterConnector = class OpenWaterConnector extends BaseRESTIntegrationConnector {
74
+ constructor() {
75
+ super(...arguments);
76
+ this.authState = null;
77
+ }
78
+ // ── Identity (three-way invariant axis) ─────────────────────────
79
+ get IntegrationName() { return 'OpenWater'; }
80
+ // ── Capability flags ─────────────────────────────────────────────
81
+ // The per-IO write columns (SupportsCreate/Update/Delete + Create/Update/Delete*
82
+ // metadata) drive the generic CRUD path; these getters report that the connector
83
+ // CAN write so the engine attempts the per-IO verbs the metadata configures.
84
+ get SupportsCreate() { return true; }
85
+ get SupportsUpdate() { return true; }
86
+ get SupportsDelete() { return true; }
87
+ // ── Sync-efficiency hooks ────────────────────────────────────────
88
+ /** OpenWater's modest enterprise throughput; engine runs an AIMD token bucket from this. */
89
+ get RateLimitPolicy() {
90
+ return { TokensPerSec: RATE_LIMIT_TOKENS_PER_SEC };
91
+ }
92
+ /** Parse a Retry-After header (seconds, or an HTTP-date) into milliseconds. */
93
+ ExtractRetryAfterMs(error) {
94
+ const retryAfter = this.ReadRetryAfterHeader(error);
95
+ if (retryAfter == null)
96
+ return undefined;
97
+ const seconds = Number.parseInt(retryAfter, 10);
98
+ if (Number.isFinite(seconds) && seconds > 0)
99
+ return seconds * 1000;
100
+ const when = Date.parse(retryAfter);
101
+ if (Number.isFinite(when)) {
102
+ const delta = when - Date.now();
103
+ return delta > 0 ? delta : 0;
104
+ }
105
+ return undefined;
106
+ }
107
+ ReadRetryAfterHeader(error) {
108
+ if (!error || typeof error !== 'object')
109
+ return undefined;
110
+ const e = error;
111
+ const status = e.Status ?? e.status;
112
+ if (status != null && status !== 429 && status !== 503)
113
+ return undefined;
114
+ const headers = e.Headers ?? e.headers;
115
+ if (!headers)
116
+ return undefined;
117
+ return headers['retry-after'] ?? headers['Retry-After'];
118
+ }
119
+ // ── Auth + transport (abstract methods) ─────────────────────────
120
+ async Authenticate(companyIntegration, contextUser) {
121
+ return this.GetAuth(companyIntegration, contextUser);
122
+ }
123
+ /** Dual custom-header auth: X-ClientKey + X-ApiKey on every request; X-OrganizationCode when present. */
124
+ BuildHeaders(auth) {
125
+ const config = auth.Config;
126
+ const headers = {
127
+ 'X-ClientKey': config.ClientKey,
128
+ 'X-ApiKey': config.ApiKey,
129
+ 'Accept': 'application/json',
130
+ 'User-Agent': 'MemberJunction-Integration/1.0',
131
+ };
132
+ if (config.OrganizationCode)
133
+ headers['X-OrganizationCode'] = config.OrganizationCode;
134
+ return headers;
135
+ }
136
+ async MakeHTTPRequest(auth, url, method, headers, body) {
137
+ const config = auth.Config;
138
+ const maxRetries = config.MaxRetries ?? DEFAULT_MAX_RETRIES;
139
+ const timeoutMs = config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
140
+ const effectiveHeaders = { ...headers };
141
+ if (body !== undefined && method !== 'GET' && method !== 'DELETE' && !effectiveHeaders['Content-Type']) {
142
+ effectiveHeaders['Content-Type'] = 'application/json';
143
+ }
144
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
145
+ const response = await this.ExecuteFetch(url, method, effectiveHeaders, body, timeoutMs);
146
+ if (this.ShouldRetry(response.status) && attempt < maxRetries) {
147
+ await this.Sleep(this.ComputeRetryDelay(response, attempt));
148
+ continue;
149
+ }
150
+ return this.BuildRESTResponse(response);
151
+ }
152
+ throw new Error(`OpenWater API request failed after ${maxRetries} attempt(s): ${url}`);
153
+ }
154
+ /**
155
+ * OpenWater v2 list endpoints return a paged envelope { records: [...], pageIndex, pageSize,
156
+ * totalRecords } or a bare array. NormalizeResponse unwraps to the record array.
157
+ */
158
+ NormalizeResponse(rawBody, responseDataKey) {
159
+ if (rawBody == null)
160
+ return [];
161
+ if (Array.isArray(rawBody))
162
+ return rawBody;
163
+ const body = rawBody;
164
+ if (responseDataKey && Array.isArray(body[responseDataKey])) {
165
+ return body[responseDataKey];
166
+ }
167
+ for (const key of ['records', 'data', 'items', 'results']) {
168
+ if (Array.isArray(body[key]))
169
+ return body[key];
170
+ }
171
+ // A single-object response (e.g. a create echo) becomes a one-element array.
172
+ if (Object.keys(body).length > 0)
173
+ return [body];
174
+ return [];
175
+ }
176
+ /**
177
+ * PageNumber pagination over OpenWater's pageIndex/pageSize. Advances pageIndex until a
178
+ * short/empty page (records.length < requested pageSize) signals the last page.
179
+ */
180
+ ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, pageSize) {
181
+ if (paginationType === 'None')
182
+ return { HasMore: false };
183
+ const records = this.NormalizeResponse(rawBody, null);
184
+ const effectivePageSize = pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE;
185
+ const total = this.ReadTotalRecords(rawBody);
186
+ switch (paginationType) {
187
+ case 'PageNumber': {
188
+ // Short page ⇒ done. Otherwise honor a totalRecords hint when the API supplies one.
189
+ const moreByPageSize = records.length >= effectivePageSize;
190
+ // pageIndex is 0-based: after page `currentPage` we've fetched (currentPage+1) pages.
191
+ const fetchedSoFar = (currentPage + 1) * effectivePageSize;
192
+ const hasMore = total != null ? fetchedSoFar < total && records.length > 0 : moreByPageSize;
193
+ return { HasMore: hasMore, NextPage: currentPage + 1 };
194
+ }
195
+ case 'Offset':
196
+ return { HasMore: records.length >= effectivePageSize, NextOffset: currentOffset + records.length };
197
+ case 'Cursor': {
198
+ const body = rawBody;
199
+ const cursor = body?.['nextCursor'] ?? body?.['next_cursor'] ?? body?.['cursor'];
200
+ return { HasMore: typeof cursor === 'string' && cursor.length > 0, NextCursor: typeof cursor === 'string' ? cursor : undefined };
201
+ }
202
+ default:
203
+ return { HasMore: records.length >= effectivePageSize };
204
+ }
205
+ }
206
+ ReadTotalRecords(rawBody) {
207
+ if (!rawBody || typeof rawBody !== 'object' || Array.isArray(rawBody))
208
+ return null;
209
+ const b = rawBody;
210
+ for (const key of ['totalRecords', 'totalCount', 'total']) {
211
+ const v = b[key];
212
+ if (typeof v === 'number' && Number.isFinite(v))
213
+ return v;
214
+ }
215
+ return null;
216
+ }
217
+ /**
218
+ * OpenWater uses page=/pageSize= by default in the base loop, but the API's param names are
219
+ * pageIndex/pageSize. Override to emit the vendor's actual param names.
220
+ */
221
+ BuildPaginatedURL(basePath, obj, page, offset, cursor, effectivePageSize) {
222
+ const pageSize = effectivePageSize ?? obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
223
+ const separator = basePath.includes('?') ? '&' : '?';
224
+ switch (obj.PaginationType) {
225
+ case 'PageNumber':
226
+ return `${basePath}${separator}pageIndex=${page}&pageSize=${pageSize}`;
227
+ case 'Offset':
228
+ return `${basePath}${separator}offset=${offset}&pageSize=${pageSize}`;
229
+ case 'Cursor':
230
+ return cursor
231
+ ? `${basePath}${separator}cursor=${encodeURIComponent(cursor)}&pageSize=${pageSize}`
232
+ : `${basePath}${separator}pageSize=${pageSize}`;
233
+ default:
234
+ return basePath;
235
+ }
236
+ }
237
+ GetBaseURL(_companyIntegration, auth) {
238
+ return auth.BaseURL;
239
+ }
240
+ // ── Connection test ──────────────────────────────────────────────
241
+ async TestConnection(companyIntegration, contextUser) {
242
+ try {
243
+ const auth = await this.GetAuth(companyIntegration, contextUser, true);
244
+ // Programs is a top-level door present in every OpenWater tenant.
245
+ const url = `${auth.BaseURL}/v2/Programs?pageIndex=0&pageSize=1`;
246
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
247
+ if (response.Status >= 200 && response.Status < 300) {
248
+ return { Success: true, Message: `Connected to OpenWater at ${auth.BaseURL}` };
249
+ }
250
+ if (response.Status === 401 || response.Status === 403) {
251
+ return { Success: false, Message: `OpenWater rejected the credentials (HTTP ${response.Status}). Verify ClientKey / ApiKey / OrganizationCode.` };
252
+ }
253
+ return { Success: false, Message: `OpenWater responded HTTP ${response.Status}.` };
254
+ }
255
+ catch (err) {
256
+ const message = err instanceof Error ? err.message : String(err);
257
+ return { Success: false, Message: `OpenWater connection failed: ${message}` };
258
+ }
259
+ }
260
+ // NOTE: DiscoverObjects / DiscoverFields / IntrospectSchema are intentionally NOT
261
+ // overridden — the base implementations read the Declared IO/IOF metadata from the
262
+ // IntegrationEngineBase cache (credential-free, no baked catalog in this file).
263
+ // ── Fetch (incremental + nested-graph access-path walk) ─────────
264
+ async FetchChanges(ctx) {
265
+ const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
266
+ const accessPath = this.ParseAccessPath(obj);
267
+ // Depth-0 (directly-queryable door / flat top-level query): delegate to the base
268
+ // flat/pagination path, but layer the incremental watermark query param on top.
269
+ if (!accessPath) {
270
+ return this.FetchDoor(ctx, obj);
271
+ }
272
+ // Depth-N (nested): walk the access path from the door to the leaf records.
273
+ return this.FetchViaAccessPath(ctx, obj, accessPath);
274
+ }
275
+ // ── Literal-create / literal-update overrides (RequiresLiveVerification) ──────────
276
+ //
277
+ // Three IOs declare CreateBodyShape='literal' (Session, JudgeAssignment, ScheduleTimeSlot)
278
+ // because their create-request schema needs fields absent from the synced list-model — the
279
+ // generic flat body would 400/422. ScheduleTimeSlot additionally declares UpdateBodyShape=
280
+ // 'literal'. CreateRecord/UpdateRecord dispatch ONLY those IOs to a hand-built body (read from
281
+ // the OpenAPI create/update-request schema) and delegate every other IO to the generic
282
+ // per-operation column path on the base class. All creates route through BuildCreatedResult.
283
+ async CreateRecord(ctx) {
284
+ switch (ctx.ObjectName) {
285
+ case 'Session': return this.CreateSession(ctx);
286
+ case 'JudgeAssignment': return this.CreateJudgeAssignment(ctx);
287
+ case 'ScheduleTimeSlot': return this.CreateScheduleTimeSlot(ctx);
288
+ default: return super.CreateRecord(ctx);
289
+ }
290
+ }
291
+ async UpdateRecord(ctx) {
292
+ // Only ScheduleTimeSlot declares UpdateBodyShape='literal'; everything else is generic.
293
+ if (ctx.ObjectName === 'ScheduleTimeSlot')
294
+ return this.UpdateScheduleTimeSlot(ctx);
295
+ return super.UpdateRecord(ctx);
296
+ }
297
+ async DeleteRecord(ctx) {
298
+ // JudgeAssignment delete has no path id (DeleteIDLocation='n/a'): DELETE /v2/JudgeAssignments/Round
299
+ // with the {roundId, judgeUserId} pair as query params, recovered from the synthetic composite id.
300
+ if (ctx.ObjectName === 'JudgeAssignment')
301
+ return this.DeleteJudgeAssignment(ctx);
302
+ return super.DeleteRecord(ctx);
303
+ }
304
+ /**
305
+ * Session create — Models.Session.CreateRequest requires {programId, typeId, name}. The synced
306
+ * list-model carries typeName (string), not typeId (int). Resolve typeId from an explicit typeId
307
+ * attribute when supplied; otherwise look it up by name against the SessionType IO (which is
308
+ * program-scoped, so we match within the record's programId).
309
+ */
310
+ async CreateSession(ctx) {
311
+ const a = ctx.Attributes;
312
+ const programId = this.RequireNumber(a, 'programId');
313
+ const typeId = await this.ResolveSessionTypeId(ctx, a, programId);
314
+ if (typeId == null) {
315
+ return this.FailedResult(`Session create: could not resolve a SessionType id from typeId/typeName for programId ${programId}.`);
316
+ }
317
+ const body = {
318
+ programId,
319
+ typeId,
320
+ name: a['name'] ?? a['Name'] ?? '',
321
+ };
322
+ this.CopyOptional(a, body, ['chairUserIds', 'fieldValues']);
323
+ return this.PostCreate(ctx, '/v2/Sessions', body, 'body');
324
+ }
325
+ /**
326
+ * JudgeAssignment create — Models.JudgeAssignment.AssignJudgeToRoundRequest requires
327
+ * {judgeUserId, roundId}. judgeUserId <- the synced userId; roundId from the create attributes'
328
+ * round context (tagged onto the synced record by the AccessPath walk, or passed explicitly).
329
+ * CreateIDLocation='n/a' → there is no body id; the stable identity is the (roundId, judgeUserId)
330
+ * pair, so BuildCreatedResult is fed the synthetic composite id roundId|judgeUserId.
331
+ */
332
+ async CreateJudgeAssignment(ctx) {
333
+ const a = ctx.Attributes;
334
+ const judgeUserId = this.FirstNumber(a, ['judgeUserId', 'userId']);
335
+ const roundId = this.FirstNumber(a, ['roundId']);
336
+ if (judgeUserId == null)
337
+ return this.FailedResult('JudgeAssignment create: missing judgeUserId/userId.');
338
+ if (roundId == null)
339
+ return this.FailedResult('JudgeAssignment create: missing roundId (round context not available).');
340
+ const body = { judgeUserId, roundId };
341
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
342
+ const url = `${auth.BaseURL}/v2/JudgeAssignments/Round`;
343
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', this.BuildHeaders(auth), body);
344
+ if (response.Status >= 200 && response.Status < 300) {
345
+ // No returned id (n/a) — the pair IS the identity. Synthesize it so the create is tracked.
346
+ return this.BuildCreatedResult(`${roundId}|${judgeUserId}`, response.Status, ctx.ObjectName);
347
+ }
348
+ return this.FailedResult(this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on JudgeAssignment create`, response.Status);
349
+ }
350
+ async DeleteJudgeAssignment(ctx) {
351
+ // Recover {roundId, judgeUserId} from the synthetic composite id roundId|judgeUserId.
352
+ const [roundId, judgeUserId] = String(ctx.ExternalID).split('|');
353
+ if (!roundId || !judgeUserId) {
354
+ return this.FailedResult(`JudgeAssignment delete: external id "${ctx.ExternalID}" is not a roundId|judgeUserId pair.`);
355
+ }
356
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
357
+ const url = `${auth.BaseURL}/v2/JudgeAssignments/Round?roundId=${encodeURIComponent(roundId)}&judgeUserId=${encodeURIComponent(judgeUserId)}`;
358
+ const response = await this.MakeHTTPRequest(auth, url, 'DELETE', this.BuildHeaders(auth));
359
+ if (response.Status >= 200 && response.Status < 300) {
360
+ return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
361
+ }
362
+ return this.FailedResult(this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on JudgeAssignment delete`, response.Status);
363
+ }
364
+ /**
365
+ * ScheduleTimeSlot create — Models.ScheduleTimeSlot.CreateRequest requires
366
+ * {name, code, startTime, endTime, scheduleDayIds}. The read-side IOF is availableOnlyInDayIds;
367
+ * map it (or an explicit scheduleDayIds) into scheduleDayIds. The path is program-scoped, so the
368
+ * generic path is bypassed and the {programId} template is filled from the attributes.
369
+ */
370
+ async CreateScheduleTimeSlot(ctx) {
371
+ const a = ctx.Attributes;
372
+ const programId = this.RequireNumber(a, 'programId');
373
+ const body = this.BuildTimeSlotBody(a);
374
+ return this.PostCreate(ctx, `/v2/Programs/${encodeURIComponent(String(programId))}/Scheduler/TimeSlots`, body, 'body');
375
+ }
376
+ /**
377
+ * ScheduleTimeSlot update — Models.ScheduleTimeSlot.UpdateRequest mirrors the create shape and
378
+ * also requires scheduleDayIds (mapped from availableOnlyInDayIds). The update path is keyed by
379
+ * the time-slot id (not program-scoped): PATCH /v2/Programs/Scheduler/TimeSlots/{scheduleTimeSlotId}.
380
+ */
381
+ async UpdateScheduleTimeSlot(ctx) {
382
+ const body = this.BuildTimeSlotBody(ctx.Attributes);
383
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
384
+ const url = `${auth.BaseURL}/v2/Programs/Scheduler/TimeSlots/${encodeURIComponent(ctx.ExternalID)}`;
385
+ const response = await this.MakeHTTPRequest(auth, url, 'PATCH', this.BuildHeaders(auth), body);
386
+ if (response.Status >= 200 && response.Status < 300) {
387
+ return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
388
+ }
389
+ return this.FailedResult(this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on ScheduleTimeSlot update`, response.Status);
390
+ }
391
+ /** Shared body for ScheduleTimeSlot create/update: maps availableOnlyInDayIds -> scheduleDayIds. */
392
+ BuildTimeSlotBody(a) {
393
+ const scheduleDayIds = a['scheduleDayIds'] ?? a['availableOnlyInDayIds'] ?? [];
394
+ return {
395
+ name: a['name'] ?? a['Name'] ?? '',
396
+ code: a['code'] ?? a['Code'] ?? '',
397
+ startTime: a['startTime'] ?? a['StartTime'],
398
+ endTime: a['endTime'] ?? a['EndTime'],
399
+ scheduleDayIds: Array.isArray(scheduleDayIds) ? scheduleDayIds : [],
400
+ };
401
+ }
402
+ /** Resolve a SessionType id: explicit typeId attribute, else lookup by typeName in the SessionType IO. */
403
+ async ResolveSessionTypeId(ctx, a, programId) {
404
+ const explicit = this.FirstNumber(a, ['typeId']);
405
+ if (explicit != null)
406
+ return explicit;
407
+ const typeName = a['typeName'] ?? a['TypeName'];
408
+ if (typeof typeName !== 'string' || typeName.length === 0)
409
+ return null;
410
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
411
+ const url = `${auth.BaseURL}/v2/Programs/${encodeURIComponent(String(programId))}/SessionTypes`;
412
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
413
+ if (response.Status < 200 || response.Status >= 300)
414
+ return null;
415
+ const types = this.NormalizeResponse(response.Body, null);
416
+ const match = types.find(t => String(t['name'] ?? t['typeName'] ?? '').toLowerCase() === typeName.toLowerCase());
417
+ if (!match)
418
+ return null;
419
+ const id = match['id'];
420
+ return typeof id === 'number' ? id : (typeof id === 'string' && id.length > 0 ? Number(id) : null);
421
+ }
422
+ /** POST a hand-built create body and route the result through BuildCreatedResult (id from body). */
423
+ async PostCreate(ctx, path, body, idLocation) {
424
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
425
+ const url = `${auth.BaseURL}${path}`;
426
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', this.BuildHeaders(auth), body);
427
+ if (response.Status >= 200 && response.Status < 300) {
428
+ const externalID = this.ExtractIDFromResponse(response, idLocation);
429
+ return this.BuildCreatedResult(externalID, response.Status, ctx.ObjectName);
430
+ }
431
+ return this.FailedResult(this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on ${ctx.ObjectName} create`, response.Status);
432
+ }
433
+ // ── Literal-write attribute helpers ──────────────────────────────
434
+ RequireNumber(a, key) {
435
+ const v = this.FirstNumber(a, [key]);
436
+ if (v == null)
437
+ throw new Error(`OpenWater write: required numeric attribute "${key}" is missing.`);
438
+ return v;
439
+ }
440
+ /** First parseable number among candidate keys (case-tolerant), or null. */
441
+ FirstNumber(a, keys) {
442
+ for (const key of keys) {
443
+ for (const k of [key, key.charAt(0).toUpperCase() + key.slice(1)]) {
444
+ const v = a[k];
445
+ if (typeof v === 'number' && Number.isFinite(v))
446
+ return v;
447
+ if (typeof v === 'string' && v.length > 0 && Number.isFinite(Number(v)))
448
+ return Number(v);
449
+ }
450
+ }
451
+ return null;
452
+ }
453
+ CopyOptional(src, dest, keys) {
454
+ for (const k of keys)
455
+ if (src[k] !== undefined)
456
+ dest[k] = src[k];
457
+ }
458
+ FailedResult(message, statusCode = 0) {
459
+ return { Success: false, StatusCode: statusCode, ErrorMessage: message };
460
+ }
461
+ /**
462
+ * Flat/door fetch with incremental watermark applied. Reuses the base pagination loop by
463
+ * temporarily folding the watermark param into the IO's DefaultQueryParams-equivalent via a
464
+ * one-shot manual paginated loop (so the watermark rides every page request).
465
+ */
466
+ async FetchDoor(ctx, obj) {
467
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
468
+ const baseURL = this.GetBaseURL(ctx.CompanyIntegration, auth);
469
+ const watermarkParam = this.BuildWatermarkParam(obj, ctx.WatermarkValue);
470
+ const path = this.AppendQuery(obj.APIPath, watermarkParam);
471
+ const fields = this.GetCachedFields(obj.ID);
472
+ const pkFieldNames = this.PrimaryKeyNames(fields);
473
+ const { records, newWatermark, hasMore, nextPage } = await this.PaginateLeaf(auth, baseURL, path, obj, ctx, undefined, watermarkParam);
474
+ return {
475
+ Records: records.map(r => this.BuildExternalRecord(r, obj, fields, pkFieldNames)),
476
+ HasMore: hasMore,
477
+ NextPage: nextPage,
478
+ NewWatermarkValue: newWatermark,
479
+ };
480
+ }
481
+ /**
482
+ * Walks the per-IO AccessPath: query the door, descend the nesting segments to the leaf parent
483
+ * ids, then fetch the entry path once per parent (id in path template or query param), unioning
484
+ * any alternativePaths. An `embedded-array` access path emits records straight from the door.
485
+ */
486
+ async FetchViaAccessPath(ctx, obj, accessPath) {
487
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
488
+ const baseURL = this.GetBaseURL(ctx.CompanyIntegration, auth);
489
+ const fields = this.GetCachedFields(obj.ID);
490
+ const pkFieldNames = this.PrimaryKeyNames(fields);
491
+ const warnings = [];
492
+ // Pull the door collection (paginated) and descend to the parent leaf values + the door rows.
493
+ const doorRows = await this.FetchDoorRows(auth, baseURL, accessPath.doorPath, obj);
494
+ if (doorRows.length === 0) {
495
+ warnings.push({
496
+ Code: 'ZERO_PARENTS',
497
+ Message: `No "${accessPath.door}" door records available; "${obj.Name}" produced zero records.`,
498
+ Data: { door: accessPath.door, doorPath: accessPath.doorPath },
499
+ });
500
+ return { Records: [], HasMore: false, Warnings: warnings };
501
+ }
502
+ // embedded-array: records are already inside the door payload (e.g. Program.rounds[]).
503
+ if (accessPath.extractionMode === 'embedded-array') {
504
+ const records = this.ExtractEmbedded(doorRows, accessPath.nestingSegments ?? []);
505
+ return {
506
+ Records: records.map(r => this.BuildExternalRecord(r, obj, fields, pkFieldNames)),
507
+ HasMore: false,
508
+ Warnings: warnings,
509
+ };
510
+ }
511
+ const parentIDs = this.DescendToParentIDs(doorRows, accessPath);
512
+ if (parentIDs.length === 0) {
513
+ warnings.push({
514
+ Code: 'ZERO_PARENTS',
515
+ Message: `No "${accessPath.parentParamName}" parent ids reachable via ${accessPath.door} ${(accessPath.nestingSegments ?? []).join('->')}; "${obj.Name}" produced zero records.`,
516
+ Data: { door: accessPath.door, parentParamName: accessPath.parentParamName ?? null },
517
+ });
518
+ return { Records: [], HasMore: false, Warnings: warnings };
519
+ }
520
+ const entryPaths = [accessPath.entryPath, ...(accessPath.alternativePaths ?? [])];
521
+ const parentTagName = accessPath.parentParamName; // e.g. roundId / programId / fundId
522
+ const out = [];
523
+ for (const parentID of parentIDs) {
524
+ for (const entryPath of entryPaths) {
525
+ const leafPath = this.InjectParentID(entryPath, parentID, accessPath);
526
+ if (leafPath == null)
527
+ continue; // template var this entry path doesn't use
528
+ const { records } = await this.PaginateLeaf(auth, baseURL, leafPath, obj, ctx, parentID, '');
529
+ for (const r of records) {
530
+ // Tag the leaf with its parent id (e.g. roundId) when the record doesn't already
531
+ // carry it — makes round/program-scoped objects self-describing for pass-through
532
+ // AND supplies the round context a literal-create write-back (JudgeAssignment) needs.
533
+ if (parentTagName && r[parentTagName] == null)
534
+ r[parentTagName] = parentID;
535
+ out.push(this.BuildExternalRecord(r, obj, fields, pkFieldNames));
536
+ }
537
+ }
538
+ }
539
+ return { Records: out, HasMore: false, Warnings: warnings };
540
+ }
541
+ /** Fetch all pages of a door collection (used to enumerate parent ids / embedded children). */
542
+ async FetchDoorRows(auth, baseURL, doorPath, obj) {
543
+ const { records } = await this.PaginateLeaf(auth, baseURL, doorPath, obj, undefined, undefined, '', true);
544
+ return records;
545
+ }
546
+ /**
547
+ * Generic paginated leaf fetch. Loops pageIndex until a short/empty page (or the batch cap when
548
+ * `ctx` is supplied), tracking the max watermark seen across the IO's IncrementalWatermarkField.
549
+ * `forceFullScan` ignores the batch cap (used when enumerating door parents).
550
+ */
551
+ async PaginateLeaf(auth, baseURL, path, obj, ctx, _parentID, _watermarkParam, forceFullScan = false) {
552
+ const headers = this.BuildHeaders(auth);
553
+ const pageSize = obj.DefaultPageSize && obj.DefaultPageSize > 0 ? obj.DefaultPageSize : DEFAULT_PAGE_SIZE;
554
+ const batchLimit = !forceFullScan && ctx?.BatchSize ? ctx.BatchSize : Number.MAX_SAFE_INTEGER;
555
+ const watermarkField = obj.IncrementalWatermarkField;
556
+ const all = [];
557
+ let page = ctx?.CurrentPage ?? 0;
558
+ let newWatermark = ctx?.WatermarkValue ?? undefined;
559
+ let hasMore = true;
560
+ while (hasMore && all.length < batchLimit) {
561
+ const sep = path.includes('?') ? '&' : '?';
562
+ const usePaging = obj.SupportsPagination && obj.PaginationType !== 'None';
563
+ const url = usePaging
564
+ ? `${baseURL}${path}${sep}pageIndex=${page}&pageSize=${pageSize}`
565
+ : `${baseURL}${path}`;
566
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
567
+ if (response.Status === 403 || response.Status === 401) {
568
+ console.warn(`[OpenWater] HTTP ${response.Status} for "${obj.Name}" at ${url} — skipping.`);
569
+ break;
570
+ }
571
+ if (response.Status < 200 || response.Status >= 300) {
572
+ throw this.HttpError(`OpenWater fetch failed for "${obj.Name}": HTTP ${response.Status}`, response);
573
+ }
574
+ const records = this.NormalizeResponse(response.Body, obj.ResponseDataKey);
575
+ if (records.length === 0)
576
+ break;
577
+ all.push(...records);
578
+ // Track max watermark seen for the incremental cursor (string/ISO-comparable).
579
+ if (watermarkField) {
580
+ for (const r of records) {
581
+ const v = r[watermarkField];
582
+ if (typeof v === 'string' && (newWatermark == null || v > newWatermark))
583
+ newWatermark = v;
584
+ }
585
+ }
586
+ if (!usePaging)
587
+ break;
588
+ const state = this.ExtractPaginationInfo(response.Body, obj.PaginationType, page, 0, pageSize);
589
+ hasMore = state.HasMore;
590
+ page = state.NextPage ?? page + 1;
591
+ }
592
+ return {
593
+ records: all,
594
+ newWatermark: watermarkField ? newWatermark : undefined,
595
+ hasMore: hasMore && all.length >= batchLimit,
596
+ nextPage: page,
597
+ };
598
+ }
599
+ // ── Access-path helpers ──────────────────────────────────────────
600
+ /** Reads and validates AccessPath from the IO's Configuration JSON; null when absent. */
601
+ ParseAccessPath(obj) {
602
+ if (!obj.Configuration)
603
+ return null;
604
+ try {
605
+ const parsed = JSON.parse(obj.Configuration);
606
+ const ap = parsed.AccessPath;
607
+ if (!ap || !ap.door || !ap.doorPath)
608
+ return null;
609
+ // embedded-array records are sliced from the DOOR payload itself — they have no
610
+ // entryPath by design. Requiring it here made every embedded IO (e.g. Rounds)
611
+ // silently fall back to its non-fetchable "(embedded in ...)" APIPath.
612
+ if (ap.extractionMode !== 'embedded-array' && !ap.entryPath)
613
+ return null;
614
+ return ap;
615
+ }
616
+ catch {
617
+ console.warn(`[OpenWater] Invalid Configuration JSON for object "${obj.Name}"`);
618
+ return null;
619
+ }
620
+ }
621
+ /**
622
+ * Descends the door rows along the nesting segments to the list of parent ids that get injected
623
+ * into the leaf entry path. For `rounds[]` this yields each round's id; for a direct programId
624
+ * parent (no nesting) it yields each door row's `id`.
625
+ */
626
+ DescendToParentIDs(doorRows, accessPath) {
627
+ // Only `[]`-suffixed segments are real array descents in the door payload (e.g. `rounds[]`,
628
+ // whose leaf `id` is the roundId). A bare segment (e.g. `transactions`) names the leaf API
629
+ // resource that lives in entryPath, NOT a nested array — those parents are the door rows
630
+ // themselves (the door's own `id` IS the parent id, e.g. programId / fundId).
631
+ const arraySegments = (accessPath.nestingSegments ?? []).filter(s => s.endsWith('[]'));
632
+ const ids = [];
633
+ const seen = new Set();
634
+ const collect = (id) => {
635
+ if (id == null)
636
+ return;
637
+ const s = String(id);
638
+ if (s.length === 0 || seen.has(s))
639
+ return;
640
+ seen.add(s);
641
+ ids.push(s);
642
+ };
643
+ if (arraySegments.length === 0) {
644
+ // Direct parent: the door row's own id is the parent id (e.g. programId, fundId).
645
+ for (const row of doorRows)
646
+ collect(row['id']);
647
+ return ids;
648
+ }
649
+ // Descend nested array segments (e.g. rounds[]), collecting the leaf nodes' ids.
650
+ const nodes = this.WalkSegments(doorRows, arraySegments);
651
+ for (const leaf of nodes)
652
+ collect(leaf['id']);
653
+ return ids;
654
+ }
655
+ /** Walks the door rows down a chain of (array) field segments, returning the leaf object nodes. */
656
+ WalkSegments(doorRows, segments) {
657
+ let nodes = doorRows;
658
+ for (const seg of segments) {
659
+ const key = seg.endsWith('[]') ? seg.slice(0, -2) : seg;
660
+ const next = [];
661
+ for (const node of nodes) {
662
+ const child = node[key];
663
+ if (Array.isArray(child)) {
664
+ for (const c of child)
665
+ if (c && typeof c === 'object')
666
+ next.push(c);
667
+ }
668
+ else if (child && typeof child === 'object') {
669
+ next.push(child);
670
+ }
671
+ }
672
+ nodes = next;
673
+ }
674
+ return nodes;
675
+ }
676
+ /** For embedded-array access paths: emit the nested records directly from the door payload. */
677
+ ExtractEmbedded(doorRows, segments) {
678
+ return this.WalkSegments(doorRows, segments);
679
+ }
680
+ /**
681
+ * Injects a parent id into an entry path: as a query param (?<parentParamName>=) when
682
+ * parentParamIn='query' (the roundId-gated endpoints, which 400 without it), otherwise into the
683
+ * {parentParamName} path template. Returns null when a path template var is present but unset
684
+ * (so an alternativePath that uses a different var is skipped, not mis-substituted).
685
+ */
686
+ InjectParentID(entryPath, parentID, accessPath) {
687
+ const paramName = accessPath.parentParamName;
688
+ const encoded = encodeURIComponent(parentID);
689
+ if (accessPath.parentParamIn === 'query' && paramName) {
690
+ const sep = entryPath.includes('?') ? '&' : '?';
691
+ return `${entryPath}${sep}${paramName}=${encoded}`;
692
+ }
693
+ // Path-template injection. Substitute the declared parent param if its placeholder is present.
694
+ if (paramName && entryPath.includes(`{${paramName}}`)) {
695
+ return entryPath.replace(`{${paramName}}`, encoded);
696
+ }
697
+ // A single remaining template var (e.g. {roundId}/{programId} in an alternativePath) also
698
+ // takes the parent id; this keeps roundId-based alternative report paths working.
699
+ const m = entryPath.match(/\{(\w+)\}/);
700
+ if (m)
701
+ return entryPath.replace(m[0], encoded);
702
+ // No template var to fill and not a query param → use as-is (already-flat entry path).
703
+ return entryPath;
704
+ }
705
+ // ── Watermark + record helpers ───────────────────────────────────
706
+ /** Builds the incremental watermark query fragment (e.g. lastModifiedSinceUtc=2026-01-01T...). */
707
+ BuildWatermarkParam(obj, watermarkValue) {
708
+ if (!obj.SupportsIncrementalSync || !obj.IncrementalWatermarkField || !watermarkValue)
709
+ return '';
710
+ return `${obj.IncrementalWatermarkField}=${encodeURIComponent(watermarkValue)}`;
711
+ }
712
+ AppendQuery(path, query) {
713
+ if (!query)
714
+ return path;
715
+ const sep = path.includes('?') ? '&' : '?';
716
+ return `${path}${sep}${query}`;
717
+ }
718
+ /**
719
+ * Builds an ExternalRecord. Fields carries the FULL source record (custom-column pass-through);
720
+ * runs the TransformRecord-preserving pipeline; resolves the ExternalID from the declared PK
721
+ * (composite-aware) with a content-hash fallback for partial/missing keys.
722
+ */
723
+ BuildExternalRecord(raw, obj, fields, pkFieldNames) {
724
+ const transformed = this.applyTransformPreservingKeys(raw, obj, fields);
725
+ const allPkPresent = pkFieldNames.length > 0
726
+ && pkFieldNames.every(name => transformed[name] != null && String(transformed[name]).length > 0);
727
+ const externalID = allPkPresent
728
+ ? pkFieldNames.map(name => String(transformed[name])).join('|')
729
+ : this.ContentHash(transformed);
730
+ return { ExternalID: externalID, ObjectType: obj.Name, Fields: transformed };
731
+ }
732
+ PrimaryKeyNames(fields) {
733
+ const pk = fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence).map(f => f.Name);
734
+ return pk.length > 0 ? pk : ['id'];
735
+ }
736
+ /** Deterministic fallback identity for partial/missing keys (FNV-1a over the canonical JSON). */
737
+ ContentHash(record) {
738
+ const json = JSON.stringify(record, Object.keys(record).sort());
739
+ let hash = 0x811c9dc5;
740
+ for (let i = 0; i < json.length; i++) {
741
+ hash ^= json.charCodeAt(i);
742
+ hash = Math.imul(hash, 0x01000193);
743
+ }
744
+ return `hash:${(hash >>> 0).toString(16)}`;
745
+ }
746
+ // ── Default config ───────────────────────────────────────────────
747
+ GetDefaultConfiguration() {
748
+ return { DefaultSchemaName: 'OpenWater', DefaultObjects: [] };
749
+ }
750
+ // ─────────────────────────────────────────────────────────────────
751
+ // Private helpers
752
+ // ─────────────────────────────────────────────────────────────────
753
+ async GetAuth(companyIntegration, contextUser, forceRefresh = false) {
754
+ if (!forceRefresh && this.authState)
755
+ return this.authState;
756
+ const config = await this.ParseConfig(companyIntegration, contextUser);
757
+ const baseURL = this.StripTrailingSlash(config.BaseURL ?? DEFAULT_BASE_URL);
758
+ this.authState = { Config: { ...config, BaseURL: baseURL }, BaseURL: baseURL };
759
+ return this.authState;
760
+ }
761
+ async ParseConfig(companyIntegration, contextUser) {
762
+ if (companyIntegration.CredentialID) {
763
+ const fromCred = await this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser);
764
+ if (fromCred)
765
+ return this.MergeConfigJson(fromCred, companyIntegration.Configuration);
766
+ }
767
+ if (companyIntegration.Configuration) {
768
+ const parsed = JSON.parse(companyIntegration.Configuration);
769
+ return this.ExtractConfig(parsed);
770
+ }
771
+ throw new Error('OpenWater connector requires either CredentialID or Configuration JSON');
772
+ }
773
+ async ParseConfigFromCredential(credentialID, contextUser, provider) {
774
+ const md = provider ?? new Metadata();
775
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
776
+ const loaded = await credential.Load(credentialID);
777
+ if (!loaded || !credential.Values)
778
+ return null;
779
+ const values = JSON.parse(credential.Values);
780
+ return this.ExtractConfig(values, true);
781
+ }
782
+ /** Overlay non-secret config (BaseURL / OrganizationCode) from the CompanyIntegration JSON onto a credential-derived config. */
783
+ MergeConfigJson(base, configJson) {
784
+ if (!configJson)
785
+ return base;
786
+ try {
787
+ const extra = this.ExtractConfig(JSON.parse(configJson), false, true);
788
+ return {
789
+ ...base,
790
+ BaseURL: extra.BaseURL ?? base.BaseURL,
791
+ OrganizationCode: extra.OrganizationCode ?? base.OrganizationCode,
792
+ ClientKey: extra.ClientKey || base.ClientKey,
793
+ };
794
+ }
795
+ catch {
796
+ return base;
797
+ }
798
+ }
799
+ /**
800
+ * Resolves the connection config from a credential / configuration value bag. ClientKey + ApiKey
801
+ * are required unless `lenient` (used when overlaying optional config JSON onto a credential).
802
+ */
803
+ ExtractConfig(values, requireApiKey = true, lenient = false) {
804
+ const get = (...keys) => {
805
+ for (const key of keys) {
806
+ const hit = Object.entries(values).find(([k]) => k.toLowerCase() === key.toLowerCase());
807
+ if (hit && hit[1] != null && String(hit[1]).length > 0)
808
+ return String(hit[1]);
809
+ }
810
+ return undefined;
811
+ };
812
+ const clientKey = get('ClientKey', 'clientKey', 'client_key', 'X-ClientKey');
813
+ const apiKey = get('ApiKey', 'apiKey', 'api_key', 'X-ApiKey');
814
+ const organizationCode = get('OrganizationCode', 'organizationCode', 'organization_code', 'X-OrganizationCode');
815
+ const baseURL = get('BaseURL', 'BaseUrl', 'base_url', 'endpoint');
816
+ if (!lenient) {
817
+ if (!clientKey)
818
+ throw new Error('OpenWater configuration missing required field: ClientKey');
819
+ if (requireApiKey && !apiKey)
820
+ throw new Error('OpenWater configuration missing required field: ApiKey');
821
+ }
822
+ return {
823
+ ClientKey: clientKey ?? '',
824
+ ApiKey: apiKey ?? '',
825
+ OrganizationCode: organizationCode,
826
+ BaseURL: baseURL ? this.StripTrailingSlash(baseURL) : undefined,
827
+ };
828
+ }
829
+ // ── HTTP helpers ─────────────────────────────────────────────────
830
+ async ExecuteFetch(url, method, headers, body, timeoutMs) {
831
+ const controller = new AbortController();
832
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
833
+ try {
834
+ const requestInit = { method, headers, signal: controller.signal };
835
+ if (body !== undefined && method !== 'GET' && method !== 'DELETE') {
836
+ requestInit.body = JSON.stringify(body);
837
+ }
838
+ return await fetch(url, requestInit);
839
+ }
840
+ finally {
841
+ clearTimeout(timer);
842
+ }
843
+ }
844
+ async BuildRESTResponse(response) {
845
+ const headers = {};
846
+ response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
847
+ const contentType = headers['content-type'] ?? '';
848
+ const body = contentType.includes('application/json')
849
+ ? await this.ParseJsonSafely(response)
850
+ : await response.text();
851
+ return { Status: response.status, Body: body, Headers: headers };
852
+ }
853
+ async ParseJsonSafely(response) {
854
+ try {
855
+ return await response.json();
856
+ }
857
+ catch {
858
+ return null;
859
+ }
860
+ }
861
+ ShouldRetry(status) {
862
+ return status === 429 || status === 502 || status === 503 || status === 504;
863
+ }
864
+ ComputeRetryDelay(response, attempt) {
865
+ const retryAfter = response.headers.get('retry-after');
866
+ if (retryAfter) {
867
+ const seconds = Number.parseInt(retryAfter, 10);
868
+ if (!Number.isNaN(seconds) && seconds > 0)
869
+ return seconds * 1000;
870
+ }
871
+ return Math.min(Math.pow(2, attempt) * 1000, 15_000);
872
+ }
873
+ /** Wraps an error message with the response Status + Headers so ExtractRetryAfterMs can read them. */
874
+ HttpError(message, response) {
875
+ const err = new Error(message);
876
+ err.Status = response.Status;
877
+ err.Headers = response.Headers;
878
+ return err;
879
+ }
880
+ Sleep(ms) {
881
+ return new Promise(resolve => setTimeout(resolve, ms));
882
+ }
883
+ StripTrailingSlash(url) {
884
+ return url.endsWith('/') ? url.slice(0, -1) : url;
885
+ }
886
+ };
887
+ OpenWaterConnector = __decorate([
888
+ RegisterClass(BaseIntegrationConnector, 'OpenWaterConnector')
889
+ ], OpenWaterConnector);
890
+ export { OpenWaterConnector };
891
+ /** Tree-shaking prevention — import and call from the module entry point. */
892
+ export function LoadOpenWaterConnector() { }
893
+ //# sourceMappingURL=OpenWaterConnector.js.map