@memberjunction/integration-connectors 5.8.0 → 5.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/HubSpotConnector.d.ts +84 -29
- package/dist/HubSpotConnector.d.ts.map +1 -1
- package/dist/HubSpotConnector.js +413 -71
- package/dist/HubSpotConnector.js.map +1 -1
- package/dist/YourMembershipConnector.d.ts +106 -39
- package/dist/YourMembershipConnector.d.ts.map +1 -1
- package/dist/YourMembershipConnector.js +568 -362
- package/dist/YourMembershipConnector.js.map +1 -1
- package/package.json +5 -5
|
@@ -5,182 +5,34 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
import { RegisterClass } from '@memberjunction/global';
|
|
8
|
-
import {
|
|
8
|
+
import { Metadata } from '@memberjunction/core';
|
|
9
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
9
10
|
/** YM REST API base URL */
|
|
10
11
|
const YM_API_BASE = 'https://ws.yourmembership.com';
|
|
11
12
|
/** Sessions expire after 14 minutes (YM sessions last ~15 min) */
|
|
12
13
|
const SESSION_TTL_MS = 14 * 60 * 1000;
|
|
13
|
-
/**
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
},
|
|
30
|
-
SupportsIncrementalSync: false,
|
|
31
|
-
},
|
|
32
|
-
Events: {
|
|
33
|
-
Path: 'Events',
|
|
34
|
-
Label: 'Events',
|
|
35
|
-
ResponseDataKey: 'EventsList',
|
|
36
|
-
PKFields: ['EventId'],
|
|
37
|
-
SupportsPagination: true,
|
|
38
|
-
DefaultPageSize: 100,
|
|
39
|
-
DefaultQueryParams: { Active: 'true' },
|
|
40
|
-
SupportsIncrementalSync: false,
|
|
41
|
-
},
|
|
42
|
-
MemberTypes: {
|
|
43
|
-
Path: 'MemberTypes',
|
|
44
|
-
Label: 'Member Types',
|
|
45
|
-
ResponseDataKey: 'MemberTypes',
|
|
46
|
-
PKFields: ['ID'],
|
|
47
|
-
SupportsPagination: false,
|
|
48
|
-
DefaultPageSize: 500,
|
|
49
|
-
SupportsIncrementalSync: false,
|
|
50
|
-
},
|
|
51
|
-
Memberships: {
|
|
52
|
-
Path: 'Memberships',
|
|
53
|
-
Label: 'Memberships',
|
|
54
|
-
ResponseDataKey: 'membershipList',
|
|
55
|
-
PKFields: ['Id'],
|
|
56
|
-
SupportsPagination: false,
|
|
57
|
-
DefaultPageSize: 500,
|
|
58
|
-
SupportsIncrementalSync: false,
|
|
59
|
-
},
|
|
60
|
-
Groups: {
|
|
61
|
-
Path: 'Groups',
|
|
62
|
-
Label: 'Groups',
|
|
63
|
-
ResponseDataKey: 'GroupTypeList',
|
|
64
|
-
PKFields: ['Id'],
|
|
65
|
-
SupportsPagination: false,
|
|
66
|
-
DefaultPageSize: 500,
|
|
67
|
-
SupportsIncrementalSync: false,
|
|
68
|
-
},
|
|
69
|
-
Products: {
|
|
70
|
-
Path: 'Products',
|
|
71
|
-
Label: 'Products',
|
|
72
|
-
ResponseDataKey: null,
|
|
73
|
-
PKFields: ['id'],
|
|
74
|
-
SupportsPagination: false,
|
|
75
|
-
DefaultPageSize: 500,
|
|
76
|
-
SupportsIncrementalSync: false,
|
|
77
|
-
},
|
|
78
|
-
DonationFunds: {
|
|
79
|
-
Path: 'DonationFunds',
|
|
80
|
-
Label: 'Donation Funds',
|
|
81
|
-
ResponseDataKey: 'fundList',
|
|
82
|
-
PKFields: ['fundId'],
|
|
83
|
-
SupportsPagination: false,
|
|
84
|
-
DefaultPageSize: 500,
|
|
85
|
-
SupportsIncrementalSync: false,
|
|
86
|
-
},
|
|
87
|
-
Certifications: {
|
|
88
|
-
Path: 'Certifications',
|
|
89
|
-
Label: 'Certifications',
|
|
90
|
-
ResponseDataKey: 'CertificationList',
|
|
91
|
-
PKFields: ['CertificationID'],
|
|
92
|
-
SupportsPagination: true,
|
|
93
|
-
DefaultPageSize: 100,
|
|
94
|
-
SupportsIncrementalSync: false,
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
/** Field definitions for known YM objects. Used by DiscoverFields. */
|
|
98
|
-
const YM_FIELD_SCHEMAS = {
|
|
99
|
-
Members: [
|
|
100
|
-
{ Name: 'ProfileID', Label: 'Profile ID', DataType: 'integer', IsRequired: true, IsUniqueKey: true, IsReadOnly: true },
|
|
101
|
-
{ Name: 'FirstName', Label: 'First Name', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
102
|
-
{ Name: 'LastName', Label: 'Last Name', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
103
|
-
{ Name: 'EmailAddr', Label: 'Email', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
104
|
-
{ Name: 'MemberTypeCode', Label: 'Member Type Code', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
105
|
-
{ Name: 'Status', Label: 'Status', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
106
|
-
{ Name: 'Organization', Label: 'Organization', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
107
|
-
{ Name: 'Phone', Label: 'Phone', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
108
|
-
{ Name: 'Address1', Label: 'Address 1', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
109
|
-
{ Name: 'Address2', Label: 'Address 2', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
110
|
-
{ Name: 'City', Label: 'City', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
111
|
-
{ Name: 'State', Label: 'State', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
112
|
-
{ Name: 'PostalCode', Label: 'Postal Code', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
113
|
-
{ Name: 'Country', Label: 'Country', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
114
|
-
{ Name: 'Title', Label: 'Title', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
115
|
-
{ Name: 'JoinDate', Label: 'Join Date', DataType: 'datetime', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
116
|
-
{ Name: 'RenewalDate', Label: 'Renewal Date', DataType: 'datetime', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
117
|
-
{ Name: 'ExpirationDate', Label: 'Expiration Date', DataType: 'datetime', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
118
|
-
{ Name: 'MemberSinceDate', Label: 'Member Since', DataType: 'datetime', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
119
|
-
{ Name: 'WebsiteUrl', Label: 'Website URL', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
120
|
-
],
|
|
121
|
-
Events: [
|
|
122
|
-
{ Name: 'EventId', Label: 'Event ID', DataType: 'integer', IsRequired: true, IsUniqueKey: true, IsReadOnly: true },
|
|
123
|
-
{ Name: 'Name', Label: 'Event Name', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
124
|
-
{ Name: 'Active', Label: 'Active', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
125
|
-
{ Name: 'StartDate', Label: 'Start Date', DataType: 'datetime', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
126
|
-
{ Name: 'EndDate', Label: 'End Date', DataType: 'datetime', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
127
|
-
{ Name: 'StartTime', Label: 'Start Time', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
128
|
-
{ Name: 'EndTime', Label: 'End Time', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
129
|
-
{ Name: 'IsVirtual', Label: 'Is Virtual', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
130
|
-
{ Name: 'VirtualMeetingType', Label: 'Virtual Meeting Type', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
131
|
-
],
|
|
132
|
-
MemberTypes: [
|
|
133
|
-
{ Name: 'ID', Label: 'Type ID', DataType: 'integer', IsRequired: true, IsUniqueKey: true, IsReadOnly: true },
|
|
134
|
-
{ Name: 'TypeCode', Label: 'Type Code', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
135
|
-
{ Name: 'Name', Label: 'Name', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
136
|
-
{ Name: 'IsDefault', Label: 'Is Default', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
137
|
-
{ Name: 'PresetType', Label: 'Preset Type', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
138
|
-
{ Name: 'SortOrder', Label: 'Sort Order', DataType: 'integer', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
139
|
-
],
|
|
140
|
-
Memberships: [
|
|
141
|
-
{ Name: 'Id', Label: 'Membership ID', DataType: 'integer', IsRequired: true, IsUniqueKey: true, IsReadOnly: true },
|
|
142
|
-
{ Name: 'Code', Label: 'Code', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
143
|
-
{ Name: 'Name', Label: 'Name', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
144
|
-
{ Name: 'DuesAmount', Label: 'Dues Amount', DataType: 'decimal', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
145
|
-
{ Name: 'ProRatedDues', Label: 'Pro-Rated Dues', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
146
|
-
{ Name: 'AllowMultipleOpenInvoices', Label: 'Allow Multiple Invoices', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
147
|
-
],
|
|
148
|
-
Groups: [
|
|
149
|
-
{ Name: 'Id', Label: 'Group ID', DataType: 'integer', IsRequired: true, IsUniqueKey: true, IsReadOnly: true },
|
|
150
|
-
{ Name: 'Name', Label: 'Group Name', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
151
|
-
{ Name: 'GroupTypeName', Label: 'Group Type', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
152
|
-
{ Name: 'GroupTypeId', Label: 'Group Type ID', DataType: 'integer', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
153
|
-
],
|
|
154
|
-
Products: [
|
|
155
|
-
{ Name: 'id', Label: 'Product ID', DataType: 'integer', IsRequired: true, IsUniqueKey: true, IsReadOnly: true },
|
|
156
|
-
{ Name: 'description', Label: 'Description', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
157
|
-
{ Name: 'amount', Label: 'Amount', DataType: 'decimal', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
158
|
-
{ Name: 'weight', Label: 'Weight', DataType: 'decimal', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
159
|
-
{ Name: 'taxRate', Label: 'Tax Rate', DataType: 'decimal', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
160
|
-
{ Name: 'quantity', Label: 'Quantity', DataType: 'integer', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
161
|
-
{ Name: 'ProductActive', Label: 'Active', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
162
|
-
{ Name: 'IsFeatured', Label: 'Is Featured', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
163
|
-
{ Name: 'ListInStore', Label: 'List In Store', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
164
|
-
{ Name: 'taxable', Label: 'Taxable', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
165
|
-
],
|
|
166
|
-
DonationFunds: [
|
|
167
|
-
{ Name: 'fundId', Label: 'Fund ID', DataType: 'integer', IsRequired: true, IsUniqueKey: true, IsReadOnly: true },
|
|
168
|
-
{ Name: 'fundName', Label: 'Fund Name', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
169
|
-
{ Name: 'fundOptionsCount', Label: 'Options Count', DataType: 'integer', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
170
|
-
],
|
|
171
|
-
Certifications: [
|
|
172
|
-
{ Name: 'CertificationID', Label: 'Certification ID', DataType: 'string', IsRequired: true, IsUniqueKey: true, IsReadOnly: true },
|
|
173
|
-
{ Name: 'ID', Label: 'ID', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
174
|
-
{ Name: 'Name', Label: 'Name', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
175
|
-
{ Name: 'IsActive', Label: 'Is Active', DataType: 'boolean', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
176
|
-
{ Name: 'CEUsRequired', Label: 'CEUs Required', DataType: 'integer', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
177
|
-
{ Name: 'Code', Label: 'Code', DataType: 'string', IsRequired: false, IsUniqueKey: false, IsReadOnly: false },
|
|
178
|
-
],
|
|
179
|
-
};
|
|
180
|
-
// ─── Connector Implementation ───────────────────────────────────
|
|
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 ───────────────────────────────────────
|
|
181
30
|
/**
|
|
182
31
|
* Connector for the YourMembership (YM) AMS REST API.
|
|
183
32
|
*
|
|
33
|
+
* Extends BaseRESTIntegrationConnector to leverage metadata-driven object/field
|
|
34
|
+
* discovery from IntegrationEngineBase cache and generic pagination handling.
|
|
35
|
+
*
|
|
184
36
|
* Auth flow:
|
|
185
37
|
* 1. POST /Ams/Authenticate with credentials + ClientID
|
|
186
38
|
* 2. Receive SessionId
|
|
@@ -188,31 +40,482 @@ const YM_FIELD_SCHEMAS = {
|
|
|
188
40
|
*
|
|
189
41
|
* Configuration JSON: { "ClientID": "25363", "APIKey": "...", "APIPassword": "..." }
|
|
190
42
|
*/
|
|
191
|
-
let YourMembershipConnector = class YourMembershipConnector extends
|
|
43
|
+
let YourMembershipConnector = class YourMembershipConnector extends BaseRESTIntegrationConnector {
|
|
192
44
|
constructor() {
|
|
193
45
|
super(...arguments);
|
|
194
46
|
/** Session cache keyed by ClientID */
|
|
195
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 };
|
|
196
398
|
}
|
|
197
|
-
// ───
|
|
399
|
+
// ─── Detail enrichment (Members) ─────────────────────────────────
|
|
198
400
|
/**
|
|
199
|
-
*
|
|
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.
|
|
200
404
|
*/
|
|
201
|
-
|
|
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
|
+
}
|
|
202
489
|
const configJson = companyIntegration.Get('Configuration');
|
|
203
|
-
if (
|
|
204
|
-
|
|
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);
|
|
205
503
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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'];
|
|
210
513
|
if (!clientId || !apiKey || !apiPassword) {
|
|
211
|
-
throw new Error('Configuration JSON must contain ClientID, APIKey, and APIPassword');
|
|
514
|
+
throw new Error('Configuration JSON must contain ClientID, APIKey, and APIPassword (any casing)');
|
|
212
515
|
}
|
|
213
|
-
return { ClientID: clientId, APIKey: apiKey, APIPassword: apiPassword };
|
|
516
|
+
return { ClientID: String(clientId), APIKey: apiKey, APIPassword: apiPassword };
|
|
214
517
|
}
|
|
215
|
-
// ─── Session management
|
|
518
|
+
// ─── Session management ──────────────────────────────────────────
|
|
216
519
|
/** Obtains or reuses a YM session for the given credentials. */
|
|
217
520
|
async GetSession(config) {
|
|
218
521
|
const cached = this.sessionCache.get(config.ClientID);
|
|
@@ -228,8 +531,8 @@ let YourMembershipConnector = class YourMembershipConnector extends BaseIntegrat
|
|
|
228
531
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
229
532
|
body: JSON.stringify({
|
|
230
533
|
provider: 'credentials',
|
|
231
|
-
UserName: config.
|
|
232
|
-
Password: config.
|
|
534
|
+
UserName: config.APIPassword,
|
|
535
|
+
Password: config.APIKey,
|
|
233
536
|
UserType: 'Admin',
|
|
234
537
|
ClientID: Number(config.ClientID),
|
|
235
538
|
}),
|
|
@@ -254,205 +557,108 @@ let YourMembershipConnector = class YourMembershipConnector extends BaseIntegrat
|
|
|
254
557
|
InvalidateSession(clientId) {
|
|
255
558
|
this.sessionCache.delete(clientId);
|
|
256
559
|
}
|
|
257
|
-
// ─── HTTP
|
|
560
|
+
// ─── HTTP helpers ────────────────────────────────────────────────
|
|
258
561
|
/**
|
|
259
|
-
* Makes a
|
|
260
|
-
*
|
|
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.
|
|
261
565
|
*/
|
|
262
|
-
async
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (response.status === 401) {
|
|
267
|
-
this.InvalidateSession(config.ClientID);
|
|
268
|
-
const newSessionId = await this.GetSession(config);
|
|
269
|
-
response = await this.FetchWithSession(url, newSessionId);
|
|
270
|
-
}
|
|
271
|
-
if (!response.ok) {
|
|
272
|
-
throw new Error(`YM API error for ${endpoint}: ${response.status} ${response.statusText}`);
|
|
273
|
-
}
|
|
274
|
-
const json = await response.json();
|
|
275
|
-
this.CheckResponseError(json, endpoint);
|
|
276
|
-
return json;
|
|
277
|
-
}
|
|
278
|
-
async FetchWithSession(url, sessionId) {
|
|
279
|
-
return fetch(url, {
|
|
280
|
-
method: 'GET',
|
|
281
|
-
headers: { 'X-SS-ID': sessionId, 'Accept': 'application/json' },
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
BuildUrl(clientId, endpoint, queryParams) {
|
|
285
|
-
const base = `${YM_API_BASE}/Ams/${clientId}/${endpoint}`;
|
|
286
|
-
if (!queryParams || Object.keys(queryParams).length === 0) {
|
|
287
|
-
return base;
|
|
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();
|
|
288
570
|
}
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
throw new Error(`YM API error for ${endpoint}: ${
|
|
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}`);
|
|
296
578
|
}
|
|
579
|
+
return response.Body;
|
|
297
580
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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);
|
|
301
585
|
try {
|
|
302
|
-
|
|
303
|
-
await this.GetSession(config);
|
|
304
|
-
const clientConfig = await this.MakeRequest(config, 'ClientConfig');
|
|
305
|
-
const siteUrl = clientConfig['SiteUrl'] ?? 'Unknown';
|
|
306
|
-
return {
|
|
307
|
-
Success: true,
|
|
308
|
-
Message: `Connected to YourMembership site: ${siteUrl}`,
|
|
309
|
-
ServerVersion: `ClientID ${config.ClientID}`,
|
|
310
|
-
};
|
|
586
|
+
return await fetch(url, { method, headers, signal: controller.signal });
|
|
311
587
|
}
|
|
312
588
|
catch (err) {
|
|
313
|
-
|
|
314
|
-
|
|
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);
|
|
315
596
|
}
|
|
316
597
|
}
|
|
317
|
-
/**
|
|
318
|
-
async
|
|
319
|
-
return
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
SupportsWrite: false,
|
|
324
|
-
}));
|
|
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
|
+
]);
|
|
325
604
|
}
|
|
326
|
-
/**
|
|
327
|
-
async
|
|
328
|
-
|
|
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;
|
|
329
611
|
}
|
|
330
|
-
/**
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
* For paginated endpoints (Members, Events, Certifications), uses
|
|
334
|
-
* PageSize/PageNumber. The watermark tracks the current page number
|
|
335
|
-
* for resuming large fetches across batches.
|
|
336
|
-
*
|
|
337
|
-
* For non-paginated endpoints (MemberTypes, Memberships, Groups, Products),
|
|
338
|
-
* fetches all data in one call.
|
|
339
|
-
*/
|
|
340
|
-
async FetchChanges(ctx) {
|
|
341
|
-
const config = this.ParseConfig(ctx.CompanyIntegration);
|
|
342
|
-
const objectConfig = YM_OBJECTS[ctx.ObjectName];
|
|
343
|
-
if (!objectConfig) {
|
|
344
|
-
throw new Error(`Unknown YourMembership object: ${ctx.ObjectName}`);
|
|
345
|
-
}
|
|
346
|
-
if (ctx.ObjectName === 'Groups') {
|
|
347
|
-
return this.FetchGroups(config, ctx);
|
|
348
|
-
}
|
|
349
|
-
if (objectConfig.ResponseDataKey === null) {
|
|
350
|
-
return this.FetchRawArray(config, ctx, objectConfig);
|
|
351
|
-
}
|
|
352
|
-
if (objectConfig.SupportsPagination) {
|
|
353
|
-
return this.FetchPaginated(config, ctx, objectConfig);
|
|
354
|
-
}
|
|
355
|
-
return this.FetchAll(config, ctx, objectConfig);
|
|
356
|
-
}
|
|
357
|
-
/** Fetches data from a paginated YM endpoint. Watermark = page number. */
|
|
358
|
-
async FetchPaginated(config, ctx, objectConfig) {
|
|
359
|
-
const pageNumber = ctx.WatermarkValue ? Number(ctx.WatermarkValue) : 1;
|
|
360
|
-
const pageSize = Math.min(ctx.BatchSize, objectConfig.DefaultPageSize);
|
|
361
|
-
const queryParams = {
|
|
362
|
-
PageSize: String(pageSize),
|
|
363
|
-
PageNumber: String(pageNumber),
|
|
364
|
-
...(objectConfig.DefaultQueryParams ?? {}),
|
|
365
|
-
};
|
|
366
|
-
const json = await this.MakeRequest(config, objectConfig.Path, queryParams);
|
|
367
|
-
const dataArray = json[objectConfig.ResponseDataKey];
|
|
368
|
-
if (!dataArray || !Array.isArray(dataArray)) {
|
|
369
|
-
return { Records: [], HasMore: false };
|
|
370
|
-
}
|
|
371
|
-
const records = dataArray.map((item) => this.BuildRecord(item, objectConfig.PKFields, ctx.ObjectName));
|
|
372
|
-
const hasMore = records.length >= pageSize;
|
|
373
|
-
const newWatermark = hasMore ? String(pageNumber + 1) : undefined;
|
|
374
|
-
return { Records: records, HasMore: hasMore, NewWatermarkValue: newWatermark };
|
|
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');
|
|
375
615
|
}
|
|
376
|
-
/**
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
if (!dataArray || !Array.isArray(dataArray)) {
|
|
381
|
-
return { Records: [], HasMore: false };
|
|
382
|
-
}
|
|
383
|
-
const records = dataArray.map((item) => this.BuildRecord(item, objectConfig.PKFields, ctx.ObjectName));
|
|
384
|
-
return { Records: records, HasMore: false };
|
|
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);
|
|
385
620
|
}
|
|
386
|
-
/**
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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}`);
|
|
393
636
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
+
}
|
|
397
645
|
}
|
|
398
|
-
|
|
399
|
-
return { Records: records, HasMore: false };
|
|
646
|
+
return result;
|
|
400
647
|
}
|
|
401
648
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
649
|
+
* Estimates record count from a raw response body.
|
|
650
|
+
* Used by ExtractPaginationInfo to determine if more pages exist.
|
|
404
651
|
*/
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
for (const groupType of typeList) {
|
|
413
|
-
const typeName = groupType.TypeName ?? '';
|
|
414
|
-
for (const group of groupType.Groups ?? []) {
|
|
415
|
-
records.push({
|
|
416
|
-
ExternalID: String(group.Id),
|
|
417
|
-
ObjectType: ctx.ObjectName,
|
|
418
|
-
Fields: { ...group, GroupTypeName: typeName, GroupTypeId: groupType.Id },
|
|
419
|
-
});
|
|
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;
|
|
420
659
|
}
|
|
421
660
|
}
|
|
422
|
-
return
|
|
423
|
-
}
|
|
424
|
-
// ─── Default field mappings ──────────────────────────────────
|
|
425
|
-
/** Returns suggested default field mappings for YM objects to MJ entities. */
|
|
426
|
-
GetDefaultFieldMappings(objectName, _entityName) {
|
|
427
|
-
switch (objectName) {
|
|
428
|
-
case 'Members':
|
|
429
|
-
return [
|
|
430
|
-
{ SourceFieldName: 'EmailAddr', DestinationFieldName: 'Email', IsKeyField: true },
|
|
431
|
-
{ SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
|
|
432
|
-
{ SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
|
|
433
|
-
{ SourceFieldName: 'Phone', DestinationFieldName: 'Phone' },
|
|
434
|
-
{ SourceFieldName: 'Organization', DestinationFieldName: 'CompanyName' },
|
|
435
|
-
{ SourceFieldName: 'MemberTypeCode', DestinationFieldName: 'Status' },
|
|
436
|
-
];
|
|
437
|
-
case 'Events':
|
|
438
|
-
return [
|
|
439
|
-
{ SourceFieldName: 'EventId', DestinationFieldName: 'ID', IsKeyField: true },
|
|
440
|
-
{ SourceFieldName: 'Name', DestinationFieldName: 'Name' },
|
|
441
|
-
{ SourceFieldName: 'StartDate', DestinationFieldName: 'StartDate' },
|
|
442
|
-
{ SourceFieldName: 'EndDate', DestinationFieldName: 'EndDate' },
|
|
443
|
-
];
|
|
444
|
-
default:
|
|
445
|
-
return [];
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
// ─── Record building ─────────────────────────────────────────
|
|
449
|
-
BuildRecord(item, pkFields, objectType) {
|
|
450
|
-
const externalId = pkFields.map(pk => String(item[pk] ?? '')).join('|');
|
|
451
|
-
return {
|
|
452
|
-
ExternalID: externalId,
|
|
453
|
-
ObjectType: objectType,
|
|
454
|
-
Fields: { ...item },
|
|
455
|
-
};
|
|
661
|
+
return 0;
|
|
456
662
|
}
|
|
457
663
|
};
|
|
458
664
|
YourMembershipConnector = __decorate([
|