@memberjunction/integration-connectors 0.0.1 → 5.9.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.
@@ -0,0 +1,668 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { RegisterClass } from '@memberjunction/global';
8
+ import { Metadata } from '@memberjunction/core';
9
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
10
+ /** YM REST API base URL */
11
+ const YM_API_BASE = 'https://ws.yourmembership.com';
12
+ /** Sessions expire after 14 minutes (YM sessions last ~15 min) */
13
+ const SESSION_TTL_MS = 14 * 60 * 1000;
14
+ /** Maximum retries for rate-limited or failed requests */
15
+ const MAX_RETRIES = 5;
16
+ /** HTTP request timeout in milliseconds */
17
+ const REQUEST_TIMEOUT_MS = 30000;
18
+ /** Minimum milliseconds between API requests to avoid rate limiting */
19
+ const MIN_REQUEST_INTERVAL_MS = 350;
20
+ /** JSON parsing timeout in milliseconds */
21
+ const JSON_TIMEOUT_MS = 30000;
22
+ /** Detail enrichment timeout per record in milliseconds */
23
+ const ENRICH_TIMEOUT_MS = 45000;
24
+ /** YM API metadata keys that should be filtered from record data */
25
+ const METADATA_KEYS = new Set([
26
+ 'ResponseStatus', 'UsingRedis', 'AppInitTime', 'ServerID',
27
+ 'ClientID', 'BypassCache', 'DateCached', 'Device',
28
+ ]);
29
+ // ─── Connector Implementation ───────────────────────────────────────
30
+ /**
31
+ * Connector for the YourMembership (YM) AMS REST API.
32
+ *
33
+ * Extends BaseRESTIntegrationConnector to leverage metadata-driven object/field
34
+ * discovery from IntegrationEngineBase cache and generic pagination handling.
35
+ *
36
+ * Auth flow:
37
+ * 1. POST /Ams/Authenticate with credentials + ClientID
38
+ * 2. Receive SessionId
39
+ * 3. Pass SessionId as X-SS-ID header on all data requests
40
+ *
41
+ * Configuration JSON: { "ClientID": "25363", "APIKey": "...", "APIPassword": "..." }
42
+ */
43
+ let YourMembershipConnector = class YourMembershipConnector extends BaseRESTIntegrationConnector {
44
+ constructor() {
45
+ super(...arguments);
46
+ /** Session cache keyed by ClientID */
47
+ this.sessionCache = new Map();
48
+ /** Timestamp of the last API request, used for throttling */
49
+ this.lastRequestTime = 0;
50
+ }
51
+ // ─── Abstract method implementations (BaseRESTIntegrationConnector) ──
52
+ async Authenticate(companyIntegration) {
53
+ const config = await this.ParseConfig(companyIntegration);
54
+ const sessionId = await this.GetSession(config);
55
+ const auth = { SessionID: sessionId, Config: config };
56
+ return auth;
57
+ }
58
+ BuildHeaders(auth) {
59
+ return { 'X-SS-ID': auth.SessionID, 'Accept': 'application/json' };
60
+ }
61
+ async MakeHTTPRequest(auth, url, method, headers, _body) {
62
+ const ymAuth = auth;
63
+ const currentHeaders = { ...headers };
64
+ // Throttle: ensure minimum interval between requests
65
+ const elapsed = Date.now() - this.lastRequestTime;
66
+ if (elapsed < MIN_REQUEST_INTERVAL_MS) {
67
+ await this.Sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
68
+ }
69
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
70
+ let response;
71
+ try {
72
+ response = await this.FetchWithTimeout(url, method, currentHeaders);
73
+ }
74
+ catch (err) {
75
+ if (this.IsTimeoutError(err)) {
76
+ console.warn(`YM timeout on ${url}, re-authenticating (attempt ${attempt + 1}/${MAX_RETRIES})`);
77
+ await this.RefreshSession(ymAuth, currentHeaders);
78
+ continue;
79
+ }
80
+ throw err;
81
+ }
82
+ if (response.status === 401) {
83
+ await this.RefreshSession(ymAuth, currentHeaders);
84
+ continue;
85
+ }
86
+ if (response.status === 429) {
87
+ const delayMs = this.CalculateRetryDelay(response, attempt);
88
+ console.warn(`YM rate limited (429), retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
89
+ await this.Sleep(delayMs);
90
+ continue;
91
+ }
92
+ this.lastRequestTime = Date.now();
93
+ const body = await this.JsonWithTimeout(response, JSON_TIMEOUT_MS);
94
+ return this.BuildRESTResponse(response, body);
95
+ }
96
+ throw new Error(`YM API request failed after ${MAX_RETRIES} retries: ${url}`);
97
+ }
98
+ NormalizeResponse(rawBody, responseDataKey) {
99
+ // Check for YM API-level errors in the response envelope
100
+ if (typeof rawBody === 'object' && rawBody !== null && !Array.isArray(rawBody)) {
101
+ this.CheckResponseError(rawBody);
102
+ }
103
+ if (responseDataKey != null) {
104
+ const body = rawBody;
105
+ const data = body[responseDataKey];
106
+ if (!data || !Array.isArray(data))
107
+ return [];
108
+ return data;
109
+ }
110
+ // Null responseDataKey: raw array (Products) or single object (EngagementScores)
111
+ if (Array.isArray(rawBody)) {
112
+ return rawBody;
113
+ }
114
+ // Single object response — filter metadata and wrap in array
115
+ return [this.FilterMetadataKeys(rawBody)];
116
+ }
117
+ ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, pageSize) {
118
+ if (paginationType === 'None') {
119
+ return { HasMore: false };
120
+ }
121
+ // YM API doesn't return total counts; infer from record count
122
+ const recordCount = this.EstimateRecordCount(rawBody);
123
+ const hasMore = recordCount >= pageSize;
124
+ return {
125
+ HasMore: hasMore,
126
+ NextPage: paginationType === 'PageNumber' ? currentPage + 1 : undefined,
127
+ NextOffset: paginationType === 'Offset' ? currentOffset + recordCount : undefined,
128
+ };
129
+ }
130
+ GetBaseURL(companyIntegration) {
131
+ const configJson = companyIntegration.Get('Configuration');
132
+ if (configJson) {
133
+ const parsed = JSON.parse(configJson);
134
+ const clientId = parsed['ClientID'] || parsed['clientId'] || parsed['ClientId'];
135
+ if (clientId)
136
+ return `${YM_API_BASE}/Ams/${clientId}`;
137
+ }
138
+ throw new Error('Cannot determine YM base URL: no ClientID in configuration');
139
+ }
140
+ // ─── YM-specific pagination parameter names ───────────────────────
141
+ /**
142
+ * Overrides base pagination URL building to use YM's expected parameter names:
143
+ * - PageNumber type: `PageNumber` / `PageSize` (not `page` / `pageSize`)
144
+ * - Offset type: `OffSet` (not `offset`); no explicit limit param — YM uses
145
+ * its own default batch size (~200 records)
146
+ */
147
+ BuildPaginatedURL(basePath, obj, page, offset, cursor) {
148
+ const separator = basePath.includes('?') ? '&' : '?';
149
+ switch (obj.PaginationType) {
150
+ case 'PageNumber':
151
+ return `${basePath}${separator}PageNumber=${page}&PageSize=${obj.DefaultPageSize}`;
152
+ case 'Offset':
153
+ return `${basePath}${separator}OffSet=${offset}&PageSize=${obj.DefaultPageSize}`;
154
+ case 'Cursor':
155
+ return cursor
156
+ ? `${basePath}${separator}cursor=${encodeURIComponent(cursor)}&limit=${obj.DefaultPageSize}`
157
+ : `${basePath}${separator}limit=${obj.DefaultPageSize}`;
158
+ default:
159
+ return basePath;
160
+ }
161
+ }
162
+ // ─── FetchChanges override for YM-specific cases ──────────────────
163
+ /**
164
+ * Overrides base FetchChanges to handle YM-specific edge cases:
165
+ * - Groups/GroupTypes: nested GroupTypeList response needs custom flattening
166
+ * - Members: client-side watermark filtering + selective detail enrichment
167
+ *
168
+ * The MemberList API returns all members every time (no server-side date filter),
169
+ * but each record includes a `LastUpdated` field. We pull the full list, filter
170
+ * to only records changed since the watermark, and only call the expensive
171
+ * per-member detail endpoint for those changed records.
172
+ */
173
+ async FetchChanges(ctx) {
174
+ if (ctx.ObjectName === 'Groups' || ctx.ObjectName === 'GroupTypes') {
175
+ return this.FetchGroups(ctx);
176
+ }
177
+ const result = await super.FetchChanges(ctx);
178
+ if (ctx.ObjectName === 'Members' && result.Records.length > 0) {
179
+ return this.FetchMembersWithWatermark(ctx, result);
180
+ }
181
+ return result;
182
+ }
183
+ /**
184
+ * Filters the full member list to only records changed since the watermark,
185
+ * then enriches only those changed records via the detail endpoint.
186
+ * Returns a new watermark based on the latest LastUpdated value seen.
187
+ */
188
+ async FetchMembersWithWatermark(ctx, fullResult) {
189
+ const { changedRecords, newWatermark } = this.FilterByWatermark(fullResult.Records, ctx.WatermarkValue, 'LastUpdated');
190
+ if (changedRecords.length === 0) {
191
+ return {
192
+ Records: [],
193
+ HasMore: false,
194
+ NewWatermarkValue: newWatermark ?? ctx.WatermarkValue ?? undefined,
195
+ };
196
+ }
197
+ console.log(`[YM Members] ${changedRecords.length} of ${fullResult.Records.length} records changed since watermark`);
198
+ const enriched = await this.EnrichMembersWithDetails(ctx, {
199
+ Records: changedRecords,
200
+ HasMore: false,
201
+ });
202
+ return {
203
+ ...enriched,
204
+ NewWatermarkValue: newWatermark ?? ctx.WatermarkValue ?? undefined,
205
+ };
206
+ }
207
+ /**
208
+ * Filters records by comparing a date field against a watermark timestamp.
209
+ * Returns only records where the date field is newer than the watermark,
210
+ * plus the latest date value seen (to use as the next watermark).
211
+ */
212
+ FilterByWatermark(records, watermarkValue, dateFieldName) {
213
+ let latestDate = null;
214
+ const watermarkDate = watermarkValue ? new Date(watermarkValue) : null;
215
+ const changed = [];
216
+ for (const record of records) {
217
+ const dateValue = record.Fields[dateFieldName];
218
+ if (dateValue == null) {
219
+ // No date field — include record (can't determine if changed)
220
+ changed.push(record);
221
+ continue;
222
+ }
223
+ const recordDate = new Date(String(dateValue));
224
+ if (isNaN(recordDate.getTime())) {
225
+ changed.push(record);
226
+ continue;
227
+ }
228
+ // Track the latest date for the new watermark
229
+ if (!latestDate || recordDate > latestDate) {
230
+ latestDate = recordDate;
231
+ }
232
+ // Include if no watermark or record is newer than watermark
233
+ if (!watermarkDate || recordDate > watermarkDate) {
234
+ changed.push(record);
235
+ }
236
+ }
237
+ const newWatermark = latestDate ? latestDate.toISOString() : null;
238
+ return { changedRecords: changed, newWatermark };
239
+ }
240
+ // ─── TestConnection ─────────────────────────────────────────────
241
+ /** Tests connectivity by authenticating and fetching ClientConfig. */
242
+ async TestConnection(companyIntegration, contextUser) {
243
+ try {
244
+ const config = await this.ParseConfig(companyIntegration, contextUser);
245
+ const sessionId = await this.GetSession(config);
246
+ const auth = { SessionID: sessionId, Config: config };
247
+ const json = await this.MakeYMRequest(auth, 'ClientConfig');
248
+ const siteUrl = json['SiteUrl'] ?? 'Unknown';
249
+ return {
250
+ Success: true,
251
+ Message: `Connected to YourMembership site: ${siteUrl}`,
252
+ ServerVersion: `ClientID ${config.ClientID}`,
253
+ };
254
+ }
255
+ catch (err) {
256
+ const message = err instanceof Error ? err.message : String(err);
257
+ return { Success: false, Message: `Connection failed: ${message}` };
258
+ }
259
+ }
260
+ // ─── Default configurations ──────────────────────────────────────
261
+ /** Returns suggested default configuration for YM integration setup. */
262
+ GetDefaultConfiguration() {
263
+ return {
264
+ DefaultSchemaName: 'YourMembership',
265
+ DefaultObjects: [], // Objects are auto-discovered from metadata via DiscoverObjects
266
+ };
267
+ }
268
+ /** Returns suggested default field mappings for common YM objects to MJ entities. */
269
+ GetDefaultFieldMappings(objectName, _entityName) {
270
+ switch (objectName) {
271
+ case 'Members':
272
+ return [
273
+ { SourceFieldName: 'EmailAddr', DestinationFieldName: 'Email', IsKeyField: true },
274
+ { SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
275
+ { SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
276
+ { SourceFieldName: 'Phone', DestinationFieldName: 'Phone' },
277
+ { SourceFieldName: 'Organization', DestinationFieldName: 'CompanyName' },
278
+ { SourceFieldName: 'MemberTypeCode', DestinationFieldName: 'Status' },
279
+ ];
280
+ case 'Events':
281
+ return [
282
+ { SourceFieldName: 'EventId', DestinationFieldName: 'ID', IsKeyField: true },
283
+ { SourceFieldName: 'Name', DestinationFieldName: 'Name' },
284
+ { SourceFieldName: 'StartDate', DestinationFieldName: 'StartDate' },
285
+ { SourceFieldName: 'EndDate', DestinationFieldName: 'EndDate' },
286
+ ];
287
+ case 'InvoiceItems':
288
+ return [
289
+ { SourceFieldName: 'LineItemID', DestinationFieldName: 'LineItemID', IsKeyField: true },
290
+ { SourceFieldName: 'InvoiceNo', DestinationFieldName: 'InvoiceNo' },
291
+ { SourceFieldName: 'InvoiceType', DestinationFieldName: 'InvoiceType' },
292
+ { SourceFieldName: 'WebSiteMemberID', DestinationFieldName: 'WebSiteMemberID' },
293
+ { SourceFieldName: 'LineItemDescription', DestinationFieldName: 'Description' },
294
+ { SourceFieldName: 'LineItemAmount', DestinationFieldName: 'Amount' },
295
+ { SourceFieldName: 'LineItemDate', DestinationFieldName: 'Date' },
296
+ ];
297
+ case 'DuesTransactions':
298
+ return [
299
+ { SourceFieldName: 'TransactionID', DestinationFieldName: 'TransactionID', IsKeyField: true },
300
+ { SourceFieldName: 'WebsiteMemberID', DestinationFieldName: 'WebsiteMemberID' },
301
+ { SourceFieldName: 'Status', DestinationFieldName: 'Status' },
302
+ { SourceFieldName: 'Amount', DestinationFieldName: 'Amount' },
303
+ { SourceFieldName: 'MembershipRequested', DestinationFieldName: 'MembershipRequested' },
304
+ { SourceFieldName: 'DateSubmitted', DestinationFieldName: 'DateSubmitted' },
305
+ ];
306
+ case 'EventRegistrations':
307
+ return [
308
+ { SourceFieldName: 'Id', DestinationFieldName: 'Id', IsKeyField: true },
309
+ { SourceFieldName: 'EventId', DestinationFieldName: 'EventId' },
310
+ { SourceFieldName: 'RegistrationID', DestinationFieldName: 'RegistrationID' },
311
+ { SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
312
+ { SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
313
+ { SourceFieldName: 'DisplayName', DestinationFieldName: 'DisplayName' },
314
+ { SourceFieldName: 'HeadShotImage', DestinationFieldName: 'HeadShotImage' },
315
+ { SourceFieldName: 'DateRegistered', DestinationFieldName: 'DateRegistered' },
316
+ { SourceFieldName: 'IsPrimary', DestinationFieldName: 'IsPrimary' },
317
+ { SourceFieldName: 'BadgeNumber', DestinationFieldName: 'BadgeNumber' },
318
+ ];
319
+ case 'EventSessions':
320
+ return [
321
+ { SourceFieldName: 'SessionId', DestinationFieldName: 'SessionId', IsKeyField: true },
322
+ { SourceFieldName: 'EventId', DestinationFieldName: 'EventId' },
323
+ { SourceFieldName: 'Name', DestinationFieldName: 'Name' },
324
+ { SourceFieldName: 'Presenter', DestinationFieldName: 'Presenter' },
325
+ { SourceFieldName: 'StartDate', DestinationFieldName: 'StartDate' },
326
+ { SourceFieldName: 'EndDate', DestinationFieldName: 'EndDate' },
327
+ ];
328
+ case 'StoreOrderDetails':
329
+ return [
330
+ { SourceFieldName: 'OrderDetailID', DestinationFieldName: 'OrderDetailID', IsKeyField: true },
331
+ { SourceFieldName: 'OrderID', DestinationFieldName: 'OrderID' },
332
+ { SourceFieldName: 'WebsiteMemberID', DestinationFieldName: 'WebsiteMemberID' },
333
+ { SourceFieldName: 'ProductName', DestinationFieldName: 'ProductName' },
334
+ { SourceFieldName: 'Quantity', DestinationFieldName: 'Quantity' },
335
+ { SourceFieldName: 'TotalPrice', DestinationFieldName: 'TotalPrice' },
336
+ ];
337
+ case 'DonationHistory':
338
+ return [
339
+ { SourceFieldName: 'intDonationId', DestinationFieldName: 'DonationId', IsKeyField: true },
340
+ { SourceFieldName: 'ProfileID', DestinationFieldName: 'ProfileID' },
341
+ { SourceFieldName: 'dblDonation', DestinationFieldName: 'Amount' },
342
+ { SourceFieldName: 'strFundName', DestinationFieldName: 'FundName' },
343
+ { SourceFieldName: 'DatDonation', DestinationFieldName: 'DonationDate' },
344
+ { SourceFieldName: 'strStatus', DestinationFieldName: 'Status' },
345
+ ];
346
+ case 'CareerOpenings':
347
+ return [
348
+ { SourceFieldName: 'CareerOpeningID', DestinationFieldName: 'CareerOpeningID', IsKeyField: true },
349
+ { SourceFieldName: 'Position', DestinationFieldName: 'Position' },
350
+ { SourceFieldName: 'Organization', DestinationFieldName: 'Organization' },
351
+ { SourceFieldName: 'DatePosted', DestinationFieldName: 'DatePosted' },
352
+ ];
353
+ default:
354
+ return [];
355
+ }
356
+ }
357
+ // ─── Groups special handling ─────────────────────────────────────
358
+ /**
359
+ * Fetches Groups or GroupTypes, which have a nested structure:
360
+ * GroupTypeList → Groups. When objectName is 'GroupTypes', returns just
361
+ * the type entries. When 'Groups', flattens into individual group records.
362
+ */
363
+ async FetchGroups(ctx) {
364
+ const auth = await this.Authenticate(ctx.CompanyIntegration);
365
+ const json = await this.MakeYMRequest(auth, 'Groups');
366
+ const typeList = json['GroupTypeList'];
367
+ if (!typeList || !Array.isArray(typeList)) {
368
+ return { Records: [], HasMore: false };
369
+ }
370
+ if (ctx.ObjectName === 'GroupTypes') {
371
+ return this.BuildGroupTypeRecords(typeList, ctx.ObjectName);
372
+ }
373
+ return this.FlattenGroupRecords(typeList, ctx.ObjectName);
374
+ }
375
+ /** Builds ExternalRecords for GroupType entries only. */
376
+ BuildGroupTypeRecords(typeList, objectType) {
377
+ const records = typeList.map(gt => ({
378
+ ExternalID: String(gt.Id ?? ''),
379
+ ObjectType: objectType,
380
+ Fields: { Id: gt.Id, TypeName: gt.TypeName, SortIndex: gt.SortIndex },
381
+ }));
382
+ return { Records: records, HasMore: false };
383
+ }
384
+ /** Flattens nested GroupTypeList → Groups into flat group records. */
385
+ FlattenGroupRecords(typeList, objectType) {
386
+ const records = [];
387
+ for (const groupType of typeList) {
388
+ const typeName = groupType.TypeName ?? '';
389
+ for (const group of groupType.Groups ?? []) {
390
+ records.push({
391
+ ExternalID: String(group.Id),
392
+ ObjectType: objectType,
393
+ Fields: { ...group, GroupTypeName: typeName, GroupTypeId: groupType.Id },
394
+ });
395
+ }
396
+ }
397
+ return { Records: records, HasMore: false };
398
+ }
399
+ // ─── Detail enrichment (Members) ─────────────────────────────────
400
+ /**
401
+ * Enriches Members records with full profile data from the detail endpoint.
402
+ * The list endpoint returns sparse data (name, email); the detail endpoint
403
+ * returns full address, phone, custom fields, etc.
404
+ */
405
+ async EnrichMembersWithDetails(ctx, result) {
406
+ const auth = await this.Authenticate(ctx.CompanyIntegration);
407
+ const concurrency = 3;
408
+ const enriched = [];
409
+ for (let i = 0; i < result.Records.length; i += concurrency) {
410
+ const batch = result.Records.slice(i, i + concurrency);
411
+ const results = await Promise.all(batch.map(record => this.EnrichSingleMember(auth, record)));
412
+ enriched.push(...results);
413
+ }
414
+ return { ...result, Records: enriched };
415
+ }
416
+ /**
417
+ * Fetches full detail data for a single member and merges it.
418
+ * Detail endpoint returns camelCase + nested objects; we normalize to flat PascalCase.
419
+ */
420
+ async EnrichSingleMember(auth, record) {
421
+ try {
422
+ const detailPath = `Members/${record.ExternalID}`;
423
+ const fetchPromise = this.MakeYMRequest(auth, detailPath);
424
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Detail fetch timed out for ${record.ExternalID}`)), ENRICH_TIMEOUT_MS));
425
+ const json = await Promise.race([fetchPromise, timeoutPromise]);
426
+ const normalized = this.NormalizeMemberDetail(json);
427
+ record.Fields = { ...record.Fields, ...normalized };
428
+ }
429
+ catch {
430
+ // Keep list data if detail fetch fails
431
+ }
432
+ return record;
433
+ }
434
+ /**
435
+ * Maps the Members detail endpoint response to our flat PascalCase schema.
436
+ * Detail endpoint returns: { firstName, lastName, emailAddress, primaryAddress: { ... } }
437
+ * We need: { FirstName, LastName, EmailAddr, Phone, Address1, ... }
438
+ */
439
+ NormalizeMemberDetail(detail) {
440
+ const result = {};
441
+ const addr = detail['primaryAddress'];
442
+ const fieldMap = {
443
+ 'id': 'ProfileID',
444
+ 'firstName': 'FirstName',
445
+ 'lastName': 'LastName',
446
+ 'emailAddress': 'EmailAddr',
447
+ 'organization': 'Organization',
448
+ 'typeCode': 'MemberTypeCode',
449
+ 'expirationDate': 'ExpirationDate',
450
+ 'isMember': 'Status',
451
+ };
452
+ for (const [detailKey, ourKey] of Object.entries(fieldMap)) {
453
+ const value = detail[detailKey];
454
+ if (value !== undefined && value !== null && value !== '') {
455
+ result[ourKey] = detailKey === 'isMember' ? (value ? 'Active' : 'Inactive') : value;
456
+ }
457
+ }
458
+ if (addr) {
459
+ const addrMap = {
460
+ 'address1': 'Address1',
461
+ 'address2': 'Address2',
462
+ 'city': 'City',
463
+ 'location': 'State',
464
+ 'postalCode': 'PostalCode',
465
+ 'countryName': 'Country',
466
+ 'phone': 'Phone',
467
+ };
468
+ for (const [addrKey, ourKey] of Object.entries(addrMap)) {
469
+ const value = addr[addrKey];
470
+ if (value !== undefined && value !== null && value !== '') {
471
+ result[ourKey] = value;
472
+ }
473
+ }
474
+ }
475
+ return result;
476
+ }
477
+ // ─── Configuration parsing ───────────────────────────────────────
478
+ /**
479
+ * Parses credentials from CompanyIntegration.CredentialID (preferred) or
480
+ * falls back to CompanyIntegration.Configuration JSON for backwards compat.
481
+ */
482
+ async ParseConfig(companyIntegration, contextUser) {
483
+ const credentialID = companyIntegration.Get('CredentialID');
484
+ if (credentialID && contextUser) {
485
+ const config = await this.LoadFromCredential(credentialID, contextUser);
486
+ if (config)
487
+ return config;
488
+ }
489
+ const configJson = companyIntegration.Get('Configuration');
490
+ if (configJson) {
491
+ return this.ParseConfigJson(configJson);
492
+ }
493
+ throw new Error('No YM credentials found. Attach a credential with ClientID, APIKey, and APIPassword, or set Configuration JSON on the CompanyIntegration.');
494
+ }
495
+ async LoadFromCredential(credentialID, contextUser) {
496
+ const md = new Metadata();
497
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
498
+ const loaded = await credential.Load(credentialID);
499
+ if (!loaded || !credential.Values)
500
+ return null;
501
+ try {
502
+ return this.ParseConfigJson(credential.Values);
503
+ }
504
+ catch {
505
+ return null;
506
+ }
507
+ }
508
+ ParseConfigJson(json) {
509
+ const parsed = JSON.parse(json);
510
+ const clientId = parsed['ClientID'] || parsed['clientId'] || parsed['ClientId'];
511
+ const apiKey = parsed['APIKey'] || parsed['apiKey'] || parsed['ApiKey'];
512
+ const apiPassword = parsed['APIPassword'] || parsed['apiPassword'] || parsed['ApiPassword'];
513
+ if (!clientId || !apiKey || !apiPassword) {
514
+ throw new Error('Configuration JSON must contain ClientID, APIKey, and APIPassword (any casing)');
515
+ }
516
+ return { ClientID: String(clientId), APIKey: apiKey, APIPassword: apiPassword };
517
+ }
518
+ // ─── Session management ──────────────────────────────────────────
519
+ /** Obtains or reuses a YM session for the given credentials. */
520
+ async GetSession(config) {
521
+ const cached = this.sessionCache.get(config.ClientID);
522
+ if (cached && (Date.now() - cached.CreatedAt) < SESSION_TTL_MS) {
523
+ return cached.SessionId;
524
+ }
525
+ return this.CreateSession(config);
526
+ }
527
+ /** Creates a new YM session via the Authenticate endpoint. */
528
+ async CreateSession(config) {
529
+ const response = await fetch(`${YM_API_BASE}/Ams/Authenticate`, {
530
+ method: 'POST',
531
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
532
+ body: JSON.stringify({
533
+ provider: 'credentials',
534
+ UserName: config.APIPassword,
535
+ Password: config.APIKey,
536
+ UserType: 'Admin',
537
+ ClientID: Number(config.ClientID),
538
+ }),
539
+ });
540
+ if (!response.ok) {
541
+ throw new Error(`YM authentication failed: ${response.status} ${response.statusText}`);
542
+ }
543
+ const json = await response.json();
544
+ if (json.ResponseStatus?.ErrorCode && json.ResponseStatus.ErrorCode !== 'None') {
545
+ throw new Error(`YM auth error: ${json.ResponseStatus.Message ?? json.ResponseStatus.ErrorCode}`);
546
+ }
547
+ if (!json.SessionId) {
548
+ throw new Error('YM auth: No SessionId returned');
549
+ }
550
+ this.sessionCache.set(config.ClientID, {
551
+ SessionId: json.SessionId,
552
+ CreatedAt: Date.now(),
553
+ });
554
+ return json.SessionId;
555
+ }
556
+ /** Invalidates a cached session so the next request re-authenticates. */
557
+ InvalidateSession(clientId) {
558
+ this.sessionCache.delete(clientId);
559
+ }
560
+ // ─── HTTP helpers ────────────────────────────────────────────────
561
+ /**
562
+ * Makes a direct YM API request using the connector's auth and URL conventions.
563
+ * Used for YM-specific calls (Groups, detail enrichment, TestConnection)
564
+ * that bypass the base class's metadata-driven flow.
565
+ */
566
+ async MakeYMRequest(auth, endpoint, queryParams) {
567
+ let url = `${YM_API_BASE}/Ams/${auth.Config.ClientID}/${endpoint}`;
568
+ if (queryParams && Object.keys(queryParams).length > 0) {
569
+ url += '?' + new URLSearchParams(queryParams).toString();
570
+ }
571
+ const headers = this.BuildHeaders(auth);
572
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
573
+ if (response.Status < 200 || response.Status >= 300) {
574
+ const bodyPreview = typeof response.Body === 'string'
575
+ ? response.Body.slice(0, 500)
576
+ : JSON.stringify(response.Body).slice(0, 500);
577
+ throw new Error(`YM API error for ${endpoint}: ${response.Status} - ${bodyPreview}`);
578
+ }
579
+ return response.Body;
580
+ }
581
+ /** Executes an HTTP request with a timeout. */
582
+ async FetchWithTimeout(url, method, headers) {
583
+ const controller = new AbortController();
584
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
585
+ try {
586
+ return await fetch(url, { method, headers, signal: controller.signal });
587
+ }
588
+ catch (err) {
589
+ if (err instanceof Error && err.name === 'AbortError') {
590
+ throw new Error(`YM API request timed out after ${REQUEST_TIMEOUT_MS / 1000}s: ${url}`);
591
+ }
592
+ throw err;
593
+ }
594
+ finally {
595
+ clearTimeout(timeoutId);
596
+ }
597
+ }
598
+ /** Wraps response.json() with a timeout to prevent indefinite hangs. */
599
+ async JsonWithTimeout(response, timeoutMs) {
600
+ return Promise.race([
601
+ response.json(),
602
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`response.json() timed out after ${timeoutMs}ms`)), timeoutMs)),
603
+ ]);
604
+ }
605
+ /** Refreshes session after 401 or timeout, updating headers in-place. */
606
+ async RefreshSession(auth, headers) {
607
+ this.InvalidateSession(auth.Config.ClientID);
608
+ const newSessionId = await this.GetSession(auth.Config);
609
+ auth.SessionID = newSessionId;
610
+ headers['X-SS-ID'] = newSessionId;
611
+ }
612
+ /** Checks if an error is a timeout/abort error. */
613
+ IsTimeoutError(err) {
614
+ return err instanceof Error && (err.message.includes('timed out') || err.name === 'AbortError');
615
+ }
616
+ /** Calculates retry delay from Retry-After header or exponential backoff. */
617
+ CalculateRetryDelay(response, attempt) {
618
+ const retryAfter = parseInt(response.headers.get('Retry-After') ?? '0', 10);
619
+ return retryAfter > 0 ? retryAfter * 1000 : Math.min(1000 * Math.pow(2, attempt), 30000);
620
+ }
621
+ /** Converts a fetch Response + parsed body into a RESTResponse. */
622
+ BuildRESTResponse(response, body) {
623
+ const headers = {};
624
+ response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
625
+ return { Status: response.status, Body: body, Headers: headers };
626
+ }
627
+ Sleep(ms) {
628
+ return new Promise(resolve => setTimeout(resolve, ms));
629
+ }
630
+ // ─── Response helpers ────────────────────────────────────────────
631
+ /** Checks for YM API-level errors in the response envelope. */
632
+ CheckResponseError(json) {
633
+ const rs = json['ResponseStatus'];
634
+ if (rs?.ErrorCode && rs.ErrorCode !== 'None' && rs.ErrorCode !== '') {
635
+ throw new Error(`YM API error: ${rs.Message ?? rs.ErrorCode}`);
636
+ }
637
+ }
638
+ /** Filters out YM API metadata keys that shouldn't be stored as field data. */
639
+ FilterMetadataKeys(data) {
640
+ const result = {};
641
+ for (const [key, value] of Object.entries(data)) {
642
+ if (!METADATA_KEYS.has(key)) {
643
+ result[key] = value;
644
+ }
645
+ }
646
+ return result;
647
+ }
648
+ /**
649
+ * Estimates record count from a raw response body.
650
+ * Used by ExtractPaginationInfo to determine if more pages exist.
651
+ */
652
+ EstimateRecordCount(rawBody) {
653
+ if (Array.isArray(rawBody))
654
+ return rawBody.length;
655
+ if (typeof rawBody === 'object' && rawBody !== null) {
656
+ for (const value of Object.values(rawBody)) {
657
+ if (Array.isArray(value))
658
+ return value.length;
659
+ }
660
+ }
661
+ return 0;
662
+ }
663
+ };
664
+ YourMembershipConnector = __decorate([
665
+ RegisterClass(BaseIntegrationConnector, 'YourMembershipConnector')
666
+ ], YourMembershipConnector);
667
+ export { YourMembershipConnector };
668
+ //# sourceMappingURL=YourMembershipConnector.js.map