@memberjunction/integration-connectors 5.34.1 → 5.36.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/AptifyConnector.d.ts +88 -0
- package/dist/AptifyConnector.d.ts.map +1 -0
- package/dist/AptifyConnector.js +737 -0
- package/dist/AptifyConnector.js.map +1 -0
- package/dist/BettyConnector.d.ts +288 -0
- package/dist/BettyConnector.d.ts.map +1 -0
- package/dist/BettyConnector.js +909 -0
- package/dist/BettyConnector.js.map +1 -0
- package/dist/BlackbaudConnector.d.ts +64 -0
- package/dist/BlackbaudConnector.d.ts.map +1 -0
- package/dist/BlackbaudConnector.js +916 -0
- package/dist/BlackbaudConnector.js.map +1 -0
- package/dist/ConstantContactConnector.d.ts +126 -0
- package/dist/ConstantContactConnector.d.ts.map +1 -0
- package/dist/ConstantContactConnector.js +636 -0
- package/dist/ConstantContactConnector.js.map +1 -0
- package/dist/GrowthZoneConnector.d.ts +185 -0
- package/dist/GrowthZoneConnector.d.ts.map +1 -0
- package/dist/GrowthZoneConnector.js +745 -0
- package/dist/GrowthZoneConnector.js.map +1 -0
- package/dist/HubSpotConnector.d.ts +2 -2
- package/dist/HubSpotConnector.d.ts.map +1 -1
- package/dist/HubSpotConnector.js +3 -3
- package/dist/HubSpotConnector.js.map +1 -1
- package/dist/IMISConnector.d.ts +155 -0
- package/dist/IMISConnector.d.ts.map +1 -0
- package/dist/IMISConnector.js +686 -0
- package/dist/IMISConnector.js.map +1 -0
- package/dist/MJToMJConnector.d.ts +279 -0
- package/dist/MJToMJConnector.d.ts.map +1 -0
- package/dist/MJToMJConnector.js +893 -0
- package/dist/MJToMJConnector.js.map +1 -0
- package/dist/MagnetMailConnector.d.ts +163 -0
- package/dist/MagnetMailConnector.d.ts.map +1 -0
- package/dist/MagnetMailConnector.js +816 -0
- package/dist/MagnetMailConnector.js.map +1 -0
- package/dist/MailchimpConnector.d.ts +188 -0
- package/dist/MailchimpConnector.d.ts.map +1 -0
- package/dist/MailchimpConnector.js +753 -0
- package/dist/MailchimpConnector.js.map +1 -0
- package/dist/NetForumConnector.d.ts +51 -0
- package/dist/NetForumConnector.d.ts.map +1 -0
- package/dist/NetForumConnector.js +419 -0
- package/dist/NetForumConnector.js.map +1 -0
- package/dist/NetSuiteConnector.d.ts +70 -0
- package/dist/NetSuiteConnector.d.ts.map +1 -0
- package/dist/NetSuiteConnector.js +605 -0
- package/dist/NetSuiteConnector.js.map +1 -0
- package/dist/NimbleAMSConnector.d.ts +49 -0
- package/dist/NimbleAMSConnector.d.ts.map +1 -0
- package/dist/NimbleAMSConnector.js +408 -0
- package/dist/NimbleAMSConnector.js.map +1 -0
- package/dist/PropFuelConnector.d.ts +68 -0
- package/dist/PropFuelConnector.d.ts.map +1 -0
- package/dist/PropFuelConnector.js +514 -0
- package/dist/PropFuelConnector.js.map +1 -0
- package/dist/RasaConnector.d.ts +26 -0
- package/dist/RasaConnector.d.ts.map +1 -1
- package/dist/RasaConnector.js +233 -1
- package/dist/RasaConnector.js.map +1 -1
- package/dist/Reach360Connector.d.ts +165 -0
- package/dist/Reach360Connector.d.ts.map +1 -0
- package/dist/Reach360Connector.js +710 -0
- package/dist/Reach360Connector.js.map +1 -0
- package/dist/SharePointConnector.d.ts +181 -0
- package/dist/SharePointConnector.d.ts.map +1 -0
- package/dist/SharePointConnector.js +796 -0
- package/dist/SharePointConnector.js.map +1 -0
- package/dist/WicketConnector.d.ts +1 -0
- package/dist/WicketConnector.d.ts.map +1 -1
- package/dist/WicketConnector.js +6 -0
- package/dist/WicketConnector.js.map +1 -1
- package/dist/WildApricotConnector.d.ts +188 -0
- package/dist/WildApricotConnector.d.ts.map +1 -0
- package/dist/WildApricotConnector.js +1092 -0
- package/dist/WildApricotConnector.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
8
|
+
import { Metadata } from '@memberjunction/core';
|
|
9
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
10
|
+
// ─── Constants ───────────────────────────────────────────────────────
|
|
11
|
+
/** Default maximum retries */
|
|
12
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
13
|
+
/** Default HTTP timeout (ms) */
|
|
14
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
15
|
+
/** Default minimum interval between requests (ms) — GrowthZone recommends 2 seconds */
|
|
16
|
+
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 2_000;
|
|
17
|
+
/** Default page size for flat list endpoints */
|
|
18
|
+
const DEFAULT_PAGE_SIZE = 500;
|
|
19
|
+
/** Sentinel date used by GrowthZone to represent null datetimes */
|
|
20
|
+
const GROWTHZONE_NULL_DATE = '0001-01-01T00:00:00';
|
|
21
|
+
/** Names of nested array fields to flatten from OrgGeneral */
|
|
22
|
+
const ORG_NESTED_ARRAYS = [
|
|
23
|
+
'Activities',
|
|
24
|
+
'Categories',
|
|
25
|
+
'Certifications',
|
|
26
|
+
'Communication',
|
|
27
|
+
'Memberships',
|
|
28
|
+
'SalesOpportunities',
|
|
29
|
+
];
|
|
30
|
+
// ─── Action-metadata objects (mirrors .growthzone.json) ──────────────
|
|
31
|
+
const GROWTHZONE_ACTION_OBJECTS = [
|
|
32
|
+
{
|
|
33
|
+
Name: 'contacts', DisplayName: 'Contact',
|
|
34
|
+
Description: 'A contact (person or organization) in GrowthZone',
|
|
35
|
+
SupportsWrite: false,
|
|
36
|
+
Fields: [
|
|
37
|
+
{ Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'GrowthZone contact identifier' },
|
|
38
|
+
{ Name: 'DisplayName', DisplayName: 'Display Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Formatted display name' },
|
|
39
|
+
{ Name: 'ModifiedDate', DisplayName: 'Modified Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modification timestamp' },
|
|
40
|
+
{ Name: 'IsDeleted', DisplayName: 'Is Deleted', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Soft-delete flag' },
|
|
41
|
+
{ Name: 'SystemContactTypeId', DisplayName: 'Contact Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Individual (1) or Organization (2)' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
Name: 'memberships', DisplayName: 'Membership',
|
|
46
|
+
Description: 'A membership held by a contact in GrowthZone',
|
|
47
|
+
SupportsWrite: false,
|
|
48
|
+
Fields: [
|
|
49
|
+
{ Name: 'MembershipId', DisplayName: 'Membership ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Membership identifier' },
|
|
50
|
+
{ Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Holder contact' },
|
|
51
|
+
{ Name: 'MembershipTypeId', DisplayName: 'Membership Type ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Membership type/package' },
|
|
52
|
+
{ Name: 'MembershipTypeName', DisplayName: 'Membership Type Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Type name' },
|
|
53
|
+
{ Name: 'MembershipStatusTypeId', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Status code (0-9)' },
|
|
54
|
+
{ Name: 'StartDate', DisplayName: 'Start Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Effective start date' },
|
|
55
|
+
{ Name: 'EndDate', DisplayName: 'End Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Expiration date' },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
Name: 'groups', DisplayName: 'Group',
|
|
60
|
+
Description: 'A group/committee in GrowthZone', SupportsWrite: false,
|
|
61
|
+
Fields: [
|
|
62
|
+
{ Name: 'GroupId', DisplayName: 'Group ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Group identifier' },
|
|
63
|
+
{ Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Group name' },
|
|
64
|
+
{ Name: 'Description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Group description' },
|
|
65
|
+
{ Name: 'IsActive', DisplayName: 'Is Active', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Active flag' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
Name: 'events', DisplayName: 'Event',
|
|
70
|
+
Description: 'An event in GrowthZone', SupportsWrite: false,
|
|
71
|
+
Fields: [
|
|
72
|
+
{ Name: 'EventId', DisplayName: 'Event ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Event identifier' },
|
|
73
|
+
{ Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event name' },
|
|
74
|
+
{ Name: 'StartDate', DisplayName: 'Start Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Start datetime' },
|
|
75
|
+
{ Name: 'EndDate', DisplayName: 'End Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'End datetime' },
|
|
76
|
+
{ Name: 'Location', DisplayName: 'Location', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event location' },
|
|
77
|
+
{ Name: 'IsActive', DisplayName: 'Is Active', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Active flag' },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
Name: 'invoices', DisplayName: 'Invoice',
|
|
82
|
+
Description: 'An invoice in GrowthZone billing', SupportsWrite: false,
|
|
83
|
+
Fields: [
|
|
84
|
+
{ Name: 'InvoiceId', DisplayName: 'Invoice ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Invoice identifier' },
|
|
85
|
+
{ Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Billed contact' },
|
|
86
|
+
{ Name: 'InvoiceNumber', DisplayName: 'Invoice Number', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User-visible invoice number' },
|
|
87
|
+
{ Name: 'TotalAmount', DisplayName: 'Total Amount', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Invoice total' },
|
|
88
|
+
{ Name: 'BalanceDue', DisplayName: 'Balance Due', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Outstanding balance' },
|
|
89
|
+
{ Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Invoice status' },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
Name: 'payments', DisplayName: 'Payment',
|
|
94
|
+
Description: 'A payment record in GrowthZone', SupportsWrite: false,
|
|
95
|
+
Fields: [
|
|
96
|
+
{ Name: 'PaymentId', DisplayName: 'Payment ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Payment identifier' },
|
|
97
|
+
{ Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Paying contact' },
|
|
98
|
+
{ Name: 'Amount', DisplayName: 'Amount', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Payment amount' },
|
|
99
|
+
{ Name: 'PaymentMethod', DisplayName: 'Payment Method', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Method (Card, ACH, Check, Cash)' },
|
|
100
|
+
{ Name: 'PaymentStatus', DisplayName: 'Payment Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Status (Pending, Captured, Refunded)' },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
Name: 'certifications', DisplayName: 'Certification',
|
|
105
|
+
Description: 'A certification held by a contact', SupportsWrite: false,
|
|
106
|
+
Fields: [
|
|
107
|
+
{ Name: 'CertificationContactId', DisplayName: 'Certification Contact ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Unique record identifier' },
|
|
108
|
+
{ Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Holder contact' },
|
|
109
|
+
{ Name: 'CertificationId', DisplayName: 'Certification ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Certification definition' },
|
|
110
|
+
{ Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Certification name' },
|
|
111
|
+
{ Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Status (InProgress, Completed, Expired)' },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
// ─── Connector Implementation ────────────────────────────────────────
|
|
116
|
+
/**
|
|
117
|
+
* Connector for the GrowthZone association management REST API.
|
|
118
|
+
*
|
|
119
|
+
* Extends BaseRESTIntegrationConnector to leverage metadata-driven object/field
|
|
120
|
+
* discovery from IntegrationEngineBase cache plus generic pagination.
|
|
121
|
+
*
|
|
122
|
+
* Auth: sends `Authorization: ApiKey {key}` on every request. API keys are
|
|
123
|
+
* provisioned by GrowthZone WebSupport. Tenant subdomain is required to
|
|
124
|
+
* resolve the base URL.
|
|
125
|
+
*
|
|
126
|
+
* Pagination: GrowthZone uses OData-style params on flat endpoints —
|
|
127
|
+
* `$skip`, `$top`, and `$orderby`. `$filter`, `$expand`, `$select` are NOT
|
|
128
|
+
* supported. The only incremental endpoint is `/api/contacts/delta`, which
|
|
129
|
+
* accepts `?modifiedSince=ISO8601&top=N`.
|
|
130
|
+
*
|
|
131
|
+
* Throttle: GrowthZone recommends 2s minimum interval; the connector defaults
|
|
132
|
+
* to that. Responds to HTTP 429/503 with exponential backoff.
|
|
133
|
+
*/
|
|
134
|
+
let GrowthZoneConnector = class GrowthZoneConnector extends BaseRESTIntegrationConnector {
|
|
135
|
+
constructor() {
|
|
136
|
+
super(...arguments);
|
|
137
|
+
/** Cached auth context for the current sync run */
|
|
138
|
+
this.authCache = null;
|
|
139
|
+
/** Timestamp of the last API request, for throttling */
|
|
140
|
+
this.lastRequestTime = 0;
|
|
141
|
+
/**
|
|
142
|
+
* Object name currently being fetched — tracked so BuildPaginatedURL can
|
|
143
|
+
* emit endpoint-specific query parameters (e.g., `modifiedSince` for delta).
|
|
144
|
+
*/
|
|
145
|
+
this.currentObjectName = '';
|
|
146
|
+
}
|
|
147
|
+
get IntegrationName() { return 'GrowthZone'; }
|
|
148
|
+
GetIntegrationObjects() {
|
|
149
|
+
return GROWTHZONE_ACTION_OBJECTS;
|
|
150
|
+
}
|
|
151
|
+
GetActionGeneratorConfig() {
|
|
152
|
+
const objects = this.GetIntegrationObjects();
|
|
153
|
+
if (objects.length === 0)
|
|
154
|
+
return null;
|
|
155
|
+
return {
|
|
156
|
+
IntegrationName: 'GrowthZone',
|
|
157
|
+
CategoryName: 'GrowthZone',
|
|
158
|
+
IconClass: 'fa-solid fa-seedling',
|
|
159
|
+
Objects: objects,
|
|
160
|
+
IncludeSearch: false,
|
|
161
|
+
IncludeList: false,
|
|
162
|
+
CategoryDescription: 'GrowthZone association management integration for contacts, memberships, events, and billing',
|
|
163
|
+
ParentCategoryName: 'Association Management',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// ── Capability getters ───────────────────────────────────────────
|
|
167
|
+
get SupportsCreate() { return false; }
|
|
168
|
+
get SupportsUpdate() { return false; }
|
|
169
|
+
get SupportsDelete() { return false; }
|
|
170
|
+
// ── Default mappings ─────────────────────────────────────────────
|
|
171
|
+
GetDefaultConfiguration() {
|
|
172
|
+
return {
|
|
173
|
+
DefaultSchemaName: 'GrowthZone',
|
|
174
|
+
DefaultObjects: [],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
GetDefaultFieldMappings(objectName, _entityName) {
|
|
178
|
+
const lower = objectName.toLowerCase();
|
|
179
|
+
if (lower === 'contacts' || lower === 'contact-person') {
|
|
180
|
+
return [
|
|
181
|
+
{ SourceFieldName: 'ContactId', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
182
|
+
{ SourceFieldName: 'DisplayName', DestinationFieldName: 'Name' },
|
|
183
|
+
{ SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
|
|
184
|
+
{ SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
|
|
185
|
+
{ SourceFieldName: 'IsDeleted', DestinationFieldName: 'IsDeleted' },
|
|
186
|
+
{ SourceFieldName: 'ModifiedDate', DestinationFieldName: 'UpdatedAt' },
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
if (lower === 'contact-org') {
|
|
190
|
+
return [
|
|
191
|
+
{ SourceFieldName: 'ContactId', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
192
|
+
{ SourceFieldName: 'OrganizationName', DestinationFieldName: 'Name' },
|
|
193
|
+
{ SourceFieldName: 'ModifiedDate', DestinationFieldName: 'UpdatedAt' },
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
if (lower === 'memberships') {
|
|
197
|
+
return [
|
|
198
|
+
{ SourceFieldName: 'MembershipId', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
199
|
+
{ SourceFieldName: 'ContactId', DestinationFieldName: 'ContactExternalID' },
|
|
200
|
+
{ SourceFieldName: 'MembershipStatusTypeId', DestinationFieldName: 'Status' },
|
|
201
|
+
{ SourceFieldName: 'StartDate', DestinationFieldName: 'StartDate' },
|
|
202
|
+
{ SourceFieldName: 'EndDate', DestinationFieldName: 'EndDate' },
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
// ─── BaseRESTIntegrationConnector abstract methods ──────────────
|
|
208
|
+
async Authenticate(companyIntegration, contextUser) {
|
|
209
|
+
if (this.authCache)
|
|
210
|
+
return this.authCache;
|
|
211
|
+
const config = await this.ParseConfig(companyIntegration, contextUser);
|
|
212
|
+
const baseUrl = this.ResolveBaseUrl(config);
|
|
213
|
+
const auth = { Token: config.ApiKey, BaseUrl: baseUrl, Config: config };
|
|
214
|
+
this.authCache = auth;
|
|
215
|
+
return auth;
|
|
216
|
+
}
|
|
217
|
+
BuildHeaders(auth) {
|
|
218
|
+
const token = auth.Token ?? '';
|
|
219
|
+
return {
|
|
220
|
+
'Authorization': `ApiKey ${token}`,
|
|
221
|
+
'Accept': 'application/json',
|
|
222
|
+
'Content-Type': 'application/json',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
GetBaseURL(_companyIntegration, auth) {
|
|
226
|
+
return auth.BaseUrl;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Extracts records from GrowthZone responses. Handles three shapes:
|
|
230
|
+
* 1. `{ Results: [...], TotalRecordAvailable: N }` — paginated lists
|
|
231
|
+
* 2. Raw array — some endpoints return arrays at the root
|
|
232
|
+
* 3. Single object — detail endpoints (per-contact fetches)
|
|
233
|
+
*
|
|
234
|
+
* Normalizes `0001-01-01` dates and empty strings to null.
|
|
235
|
+
*/
|
|
236
|
+
NormalizeResponse(rawBody, responseDataKey) {
|
|
237
|
+
if (rawBody == null)
|
|
238
|
+
return [];
|
|
239
|
+
if (Array.isArray(rawBody)) {
|
|
240
|
+
return rawBody.map(r => this.NormalizeRecord(r));
|
|
241
|
+
}
|
|
242
|
+
if (typeof rawBody === 'object') {
|
|
243
|
+
const body = rawBody;
|
|
244
|
+
// Paginated envelope: { Results: [...], TotalRecordAvailable: N }
|
|
245
|
+
const envelope = body;
|
|
246
|
+
if (Array.isArray(envelope.Results)) {
|
|
247
|
+
return envelope.Results.map(r => this.NormalizeRecord(r));
|
|
248
|
+
}
|
|
249
|
+
// Custom response key from metadata
|
|
250
|
+
if (responseDataKey && Array.isArray(body[responseDataKey])) {
|
|
251
|
+
const arr = body[responseDataKey];
|
|
252
|
+
return arr.map(r => this.NormalizeRecord(r));
|
|
253
|
+
}
|
|
254
|
+
// Nested flattening cases for OrgGeneral/NotesAndFields
|
|
255
|
+
const flattened = this.MaybeFlattenNested(body);
|
|
256
|
+
if (flattened)
|
|
257
|
+
return flattened;
|
|
258
|
+
// Single detail record
|
|
259
|
+
return [this.NormalizeRecord(body)];
|
|
260
|
+
}
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Extracts pagination state. GrowthZone uses OData pagination with
|
|
265
|
+
* `$skip` and `$top`; there's no `HasMore` flag — we derive it from
|
|
266
|
+
* `TotalRecordAvailable` vs. current offset + returned count.
|
|
267
|
+
*/
|
|
268
|
+
ExtractPaginationInfo(rawBody, _paginationType, _currentPage, currentOffset, pageSize) {
|
|
269
|
+
if (!rawBody || typeof rawBody !== 'object') {
|
|
270
|
+
return { HasMore: false };
|
|
271
|
+
}
|
|
272
|
+
const body = rawBody;
|
|
273
|
+
const results = Array.isArray(body.Results) ? body.Results : [];
|
|
274
|
+
const total = typeof body.TotalRecordAvailable === 'number'
|
|
275
|
+
? body.TotalRecordAvailable
|
|
276
|
+
: undefined;
|
|
277
|
+
// Fewer records than requested = end of stream
|
|
278
|
+
if (results.length < pageSize) {
|
|
279
|
+
return { HasMore: false, TotalRecords: total };
|
|
280
|
+
}
|
|
281
|
+
// Check against total count if provided
|
|
282
|
+
const nextOffset = currentOffset + results.length;
|
|
283
|
+
if (total !== undefined && nextOffset >= total) {
|
|
284
|
+
return { HasMore: false, TotalRecords: total };
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
HasMore: true,
|
|
288
|
+
NextOffset: nextOffset,
|
|
289
|
+
TotalRecords: total,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Overrides the base pagination URL builder to emit GrowthZone-specific
|
|
294
|
+
* OData parameters (`$skip`, `$top`) and, for the delta contacts endpoint,
|
|
295
|
+
* the `modifiedSince` watermark parameter.
|
|
296
|
+
*
|
|
297
|
+
* Driven entirely by IntegrationObject metadata — no hardcoded object-name
|
|
298
|
+
* switches beyond the delta-specific param which is keyed on APIPath.
|
|
299
|
+
*/
|
|
300
|
+
BuildPaginatedURL(basePath, obj, _page, offset, _cursor, effectivePageSize) {
|
|
301
|
+
const limit = effectivePageSize ?? obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
|
|
302
|
+
const params = new URLSearchParams();
|
|
303
|
+
// GrowthZone's only incremental endpoint: /api/contacts/delta
|
|
304
|
+
const isDelta = obj.APIPath?.toLowerCase().endsWith('/delta') ?? false;
|
|
305
|
+
if (isDelta && this.currentWatermark) {
|
|
306
|
+
params.set('modifiedSince', this.currentWatermark);
|
|
307
|
+
params.set('top', String(limit));
|
|
308
|
+
if (offset > 0)
|
|
309
|
+
params.set('skip', String(offset));
|
|
310
|
+
}
|
|
311
|
+
else if (obj.SupportsPagination && obj.PaginationType === 'Offset') {
|
|
312
|
+
params.set('$top', String(limit));
|
|
313
|
+
if (offset > 0)
|
|
314
|
+
params.set('$skip', String(offset));
|
|
315
|
+
}
|
|
316
|
+
const qs = params.toString();
|
|
317
|
+
return qs ? `${basePath}?${qs}` : basePath;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Executes an HTTP request with rate limiting and retry for 429/503.
|
|
321
|
+
*/
|
|
322
|
+
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
323
|
+
const gzAuth = auth;
|
|
324
|
+
const maxRetries = gzAuth.Config.MaxRetries ?? DEFAULT_MAX_RETRIES;
|
|
325
|
+
const timeoutMs = gzAuth.Config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
326
|
+
const minInterval = gzAuth.Config.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
|
|
327
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
328
|
+
await this.ThrottleIfNeeded(minInterval);
|
|
329
|
+
const fetchOptions = {
|
|
330
|
+
method,
|
|
331
|
+
headers,
|
|
332
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
333
|
+
};
|
|
334
|
+
if (body !== undefined && method !== 'GET') {
|
|
335
|
+
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
336
|
+
}
|
|
337
|
+
let response;
|
|
338
|
+
try {
|
|
339
|
+
response = await fetch(url, fetchOptions);
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
if (attempt < maxRetries && this.IsTransientNetworkError(err)) {
|
|
343
|
+
await this.Sleep(this.BackoffDelay(attempt));
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
this.lastRequestTime = Date.now();
|
|
349
|
+
if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
|
|
350
|
+
console.warn(`[GrowthZone] HTTP ${response.status} from ${url} — backing off`);
|
|
351
|
+
await this.Sleep(this.BackoffDelay(attempt));
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
return this.BuildRESTResponse(response);
|
|
355
|
+
}
|
|
356
|
+
throw new Error(`GrowthZone request failed after ${maxRetries + 1} attempts: ${url}`);
|
|
357
|
+
}
|
|
358
|
+
// ─── TestConnection ──────────────────────────────────────────────
|
|
359
|
+
/**
|
|
360
|
+
* Tests connectivity by calling the delta endpoint with a recent
|
|
361
|
+
* watermark. A 200 response confirms that the tenant and API key are
|
|
362
|
+
* valid.
|
|
363
|
+
*/
|
|
364
|
+
async TestConnection(companyIntegration, contextUser) {
|
|
365
|
+
try {
|
|
366
|
+
const config = await this.ParseConfig(companyIntegration, contextUser);
|
|
367
|
+
const baseUrl = this.ResolveBaseUrl(config);
|
|
368
|
+
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
369
|
+
const url = `${baseUrl}/contacts/delta?modifiedSince=${encodeURIComponent(since)}&top=1`;
|
|
370
|
+
const response = await fetch(url, {
|
|
371
|
+
method: 'GET',
|
|
372
|
+
headers: {
|
|
373
|
+
'Authorization': `ApiKey ${config.ApiKey}`,
|
|
374
|
+
'Accept': 'application/json',
|
|
375
|
+
},
|
|
376
|
+
signal: AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS),
|
|
377
|
+
});
|
|
378
|
+
if (!response.ok) {
|
|
379
|
+
const preview = (await response.text()).slice(0, 300);
|
|
380
|
+
return {
|
|
381
|
+
Success: false,
|
|
382
|
+
Message: `GrowthZone returned HTTP ${response.status} from ${url}: ${preview}`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
Success: true,
|
|
387
|
+
Message: `Connected to GrowthZone tenant '${config.Tenant}'`,
|
|
388
|
+
ServerVersion: 'GrowthZone REST API',
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
393
|
+
return { Success: false, Message: `Connection failed: ${message}` };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// ─── Discovery with runtime custom fields ───────────────────────
|
|
397
|
+
/**
|
|
398
|
+
* Discovers objects from the cache. GrowthZone's dynamic capability is
|
|
399
|
+
* limited to custom fields on contacts (not custom objects/tables) —
|
|
400
|
+
* so DiscoverObjects only returns the metadata-seeded baseline.
|
|
401
|
+
*/
|
|
402
|
+
async DiscoverObjects(companyIntegration, contextUser) {
|
|
403
|
+
return super.DiscoverObjects(companyIntegration, contextUser);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Discovers fields for a given object, augmenting the cached metadata
|
|
407
|
+
* with runtime-discovered custom fields for contact objects. Custom
|
|
408
|
+
* fields get IsCustom=true via IntegrationSchemaSync when persisted.
|
|
409
|
+
*/
|
|
410
|
+
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
411
|
+
const staticFields = await super.DiscoverFields(companyIntegration, objectName, contextUser);
|
|
412
|
+
// Custom-field discovery only applies to the contact-custom-field-values
|
|
413
|
+
// and custom-field-definitions objects.
|
|
414
|
+
const lower = objectName.toLowerCase();
|
|
415
|
+
if (lower !== 'custom-field-definitions' && lower !== 'contact-custom-field-values') {
|
|
416
|
+
return staticFields;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const customFields = await this.FetchCustomFieldDefinitions(companyIntegration, contextUser);
|
|
420
|
+
const discovered = customFields.map(cf => this.CustomFieldToSchema(cf));
|
|
421
|
+
return [...staticFields, ...discovered];
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
425
|
+
console.warn(`[GrowthZone] Runtime custom-field discovery failed: ${message}`);
|
|
426
|
+
return staticFields;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Fetches custom field definitions from GrowthZone. Uses a sample contact
|
|
431
|
+
* ID to query NotesAndFields — since the curated API does not expose a
|
|
432
|
+
* global custom-fields list endpoint, a sample contact is used to probe
|
|
433
|
+
* the defined fields.
|
|
434
|
+
*/
|
|
435
|
+
async FetchCustomFieldDefinitions(companyIntegration, contextUser) {
|
|
436
|
+
const auth = (await this.Authenticate(companyIntegration, contextUser));
|
|
437
|
+
const headers = this.BuildHeaders(auth);
|
|
438
|
+
// Canonical vendor endpoint: POST /api/customfields/getall returns ALL
|
|
439
|
+
// tenant-defined custom field definitions across all object types in one
|
|
440
|
+
// call. Documented at documentation.growthzoneapp.com/CustomField.html.
|
|
441
|
+
// Previously this method probed per-contact NotesAndFields, which was
|
|
442
|
+
// both slow and limited to fields actually attached to the probed record.
|
|
443
|
+
try {
|
|
444
|
+
const url = `${auth.BaseUrl}/customfields/getall`;
|
|
445
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, {});
|
|
446
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
447
|
+
const body = response.Body;
|
|
448
|
+
const items = Array.isArray(body) ? body : (body.Results ?? []);
|
|
449
|
+
if (items.length > 0)
|
|
450
|
+
return items;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// Fall through to legacy per-contact probe if the global endpoint isn't
|
|
455
|
+
// available on the tenant (e.g., older deployments may not expose it).
|
|
456
|
+
}
|
|
457
|
+
// Legacy fallback: probe a single contact's NotesAndFields. Kept so older
|
|
458
|
+
// GrowthZone deployments without /customfields/getall continue to work.
|
|
459
|
+
const since = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
|
|
460
|
+
const listUrl = `${auth.BaseUrl}/contacts/delta?modifiedSince=${encodeURIComponent(since)}&top=1`;
|
|
461
|
+
const listResp = await this.MakeHTTPRequest(auth, listUrl, 'GET', headers);
|
|
462
|
+
if (listResp.Status < 200 || listResp.Status >= 300)
|
|
463
|
+
return [];
|
|
464
|
+
const listBody = listResp.Body;
|
|
465
|
+
const contacts = Array.isArray(listBody) ? listBody : (listBody.Results ?? []);
|
|
466
|
+
if (contacts.length === 0)
|
|
467
|
+
return [];
|
|
468
|
+
const probeId = String(contacts[0].ContactId);
|
|
469
|
+
const fieldsUrl = `${auth.BaseUrl}/contacts/${encodeURIComponent(probeId)}/NotesAndFields`;
|
|
470
|
+
const fieldsResp = await this.MakeHTTPRequest(auth, fieldsUrl, 'GET', headers);
|
|
471
|
+
if (fieldsResp.Status < 200 || fieldsResp.Status >= 300)
|
|
472
|
+
return [];
|
|
473
|
+
const notesAndFields = fieldsResp.Body;
|
|
474
|
+
return notesAndFields.Fields ?? [];
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Maps a GrowthZone custom field definition to an ExternalFieldSchema.
|
|
478
|
+
*/
|
|
479
|
+
CustomFieldToSchema(cf) {
|
|
480
|
+
return {
|
|
481
|
+
Name: `CustomField_${cf.CustomFieldId}`,
|
|
482
|
+
Label: cf.DisplayName ?? cf.Name ?? `Custom Field ${cf.CustomFieldId}`,
|
|
483
|
+
Description: cf.Description ?? undefined,
|
|
484
|
+
DataType: this.MapCustomFieldType(cf.CustomFieldType),
|
|
485
|
+
IsRequired: cf.IsRequired === true,
|
|
486
|
+
IsUniqueKey: false,
|
|
487
|
+
IsReadOnly: cf.IsArchived === true,
|
|
488
|
+
IsForeignKey: false,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Maps GrowthZone custom-field type to generic integration type.
|
|
493
|
+
*/
|
|
494
|
+
MapCustomFieldType(cfType) {
|
|
495
|
+
const t = (cfType ?? '').toLowerCase();
|
|
496
|
+
if (t === 'number')
|
|
497
|
+
return 'decimal';
|
|
498
|
+
if (t === 'date')
|
|
499
|
+
return 'datetime';
|
|
500
|
+
if (t === 'selectlist' || t === 'scale')
|
|
501
|
+
return 'string';
|
|
502
|
+
if (t === 'file')
|
|
503
|
+
return 'string';
|
|
504
|
+
return 'string';
|
|
505
|
+
}
|
|
506
|
+
// ─── FetchChanges override ──────────────────────────────────────
|
|
507
|
+
/**
|
|
508
|
+
* Override to store watermark/object-name context used by BuildPaginatedURL
|
|
509
|
+
* and to advance the watermark from returned records.
|
|
510
|
+
*/
|
|
511
|
+
async FetchChanges(ctx) {
|
|
512
|
+
this.currentObjectName = ctx.ObjectName;
|
|
513
|
+
this.currentWatermark = ctx.WatermarkValue ?? undefined;
|
|
514
|
+
const result = await super.FetchChanges(ctx);
|
|
515
|
+
const isFinal = !result.HasMore;
|
|
516
|
+
const newWatermark = isFinal
|
|
517
|
+
? (this.ExtractLatestModifiedDate(result.Records) ?? ctx.WatermarkValue ?? undefined)
|
|
518
|
+
: undefined;
|
|
519
|
+
return {
|
|
520
|
+
...result,
|
|
521
|
+
NewWatermarkValue: newWatermark,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
// ─── Private helpers ────────────────────────────────────────────
|
|
525
|
+
/**
|
|
526
|
+
* Normalizes a raw record: strips GrowthZone null-sentinel dates and
|
|
527
|
+
* treats empty strings as null. Does NOT recurse into nested objects
|
|
528
|
+
* beyond the top level — the base class handles that.
|
|
529
|
+
*/
|
|
530
|
+
NormalizeRecord(record) {
|
|
531
|
+
const out = {};
|
|
532
|
+
for (const [key, value] of Object.entries(record)) {
|
|
533
|
+
out[key] = this.NormalizeValue(value);
|
|
534
|
+
}
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
/** Converts GrowthZone sentinel values to null. */
|
|
538
|
+
NormalizeValue(value) {
|
|
539
|
+
if (value == null)
|
|
540
|
+
return null;
|
|
541
|
+
if (typeof value === 'string') {
|
|
542
|
+
if (value === '')
|
|
543
|
+
return null;
|
|
544
|
+
if (value.startsWith(GROWTHZONE_NULL_DATE))
|
|
545
|
+
return null;
|
|
546
|
+
return value;
|
|
547
|
+
}
|
|
548
|
+
return value;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* For OrgGeneral / NotesAndFields responses, the current object being
|
|
552
|
+
* fetched may request a flattened nested array rather than the root
|
|
553
|
+
* record. Returns flattened rows when currentObjectName matches a
|
|
554
|
+
* known nested-array target, else null.
|
|
555
|
+
*/
|
|
556
|
+
MaybeFlattenNested(body) {
|
|
557
|
+
const name = this.currentObjectName.toLowerCase();
|
|
558
|
+
// NotesAndFields — flatten Fields[] array for custom field values
|
|
559
|
+
if (name === 'contact-custom-field-values' && Array.isArray(body.Fields)) {
|
|
560
|
+
const fields = body.Fields;
|
|
561
|
+
const contactId = body.ContactId ?? body.contactId;
|
|
562
|
+
return fields.map(cf => this.NormalizeRecord({
|
|
563
|
+
ContactId: contactId ?? '',
|
|
564
|
+
CustomFieldId: cf.CustomFieldId,
|
|
565
|
+
Name: cf.Name ?? null,
|
|
566
|
+
DisplayName: cf.DisplayName ?? null,
|
|
567
|
+
CustomFieldType: cf.CustomFieldType ?? null,
|
|
568
|
+
Value: this.StringifyCustomFieldValue(cf.Value),
|
|
569
|
+
FieldGroupName: cf.FieldGroupName ?? null,
|
|
570
|
+
IsPublic: cf.IsPublic ?? null,
|
|
571
|
+
IsArchived: cf.IsArchived ?? null,
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
// OrgGeneral — flatten one of the nested arrays based on current object
|
|
575
|
+
const nestedMap = {
|
|
576
|
+
'contact-activities': 'Activities',
|
|
577
|
+
'contact-categories': 'Categories',
|
|
578
|
+
'contact-communications': 'Communication',
|
|
579
|
+
'contact-sales-opportunities': 'SalesOpportunities',
|
|
580
|
+
};
|
|
581
|
+
const arrKey = nestedMap[name];
|
|
582
|
+
if (arrKey && Array.isArray(body[arrKey])) {
|
|
583
|
+
const rows = body[arrKey];
|
|
584
|
+
const contactId = body.ContactId ?? body.contactId;
|
|
585
|
+
return rows.map(row => this.NormalizeRecord({
|
|
586
|
+
ContactId: contactId ?? '',
|
|
587
|
+
...row,
|
|
588
|
+
}));
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
/** Stringifies custom-field values so they fit nvarchar storage. */
|
|
593
|
+
StringifyCustomFieldValue(value) {
|
|
594
|
+
if (value == null)
|
|
595
|
+
return null;
|
|
596
|
+
if (typeof value === 'string')
|
|
597
|
+
return value;
|
|
598
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
599
|
+
return String(value);
|
|
600
|
+
try {
|
|
601
|
+
return JSON.stringify(value);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Extracts the latest ModifiedDate from a batch of records for
|
|
609
|
+
* watermark advancement.
|
|
610
|
+
*/
|
|
611
|
+
ExtractLatestModifiedDate(records) {
|
|
612
|
+
let latest = null;
|
|
613
|
+
for (const rec of records) {
|
|
614
|
+
const dateStr = rec.Fields?.ModifiedDate;
|
|
615
|
+
if (typeof dateStr !== 'string' || dateStr.length === 0)
|
|
616
|
+
continue;
|
|
617
|
+
const d = new Date(dateStr);
|
|
618
|
+
if (!isNaN(d.getTime()) && (latest === null || d > latest)) {
|
|
619
|
+
latest = d;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return latest ? latest.toISOString() : null;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Parses connection config, preferring the attached credential entity
|
|
626
|
+
* over the raw Configuration JSON.
|
|
627
|
+
*/
|
|
628
|
+
async ParseConfig(companyIntegration, contextUser) {
|
|
629
|
+
if (companyIntegration.CredentialID) {
|
|
630
|
+
return this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser);
|
|
631
|
+
}
|
|
632
|
+
if (companyIntegration.Configuration) {
|
|
633
|
+
return this.ValidateConfig(JSON.parse(companyIntegration.Configuration));
|
|
634
|
+
}
|
|
635
|
+
throw new Error('GrowthZone connector requires either CredentialID or Configuration JSON');
|
|
636
|
+
}
|
|
637
|
+
/** Loads config from the MJ: Credentials entity. */
|
|
638
|
+
async ParseConfigFromCredential(credentialID, contextUser, provider) {
|
|
639
|
+
const md = provider ?? new Metadata();
|
|
640
|
+
const cred = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
641
|
+
await cred.Load(credentialID);
|
|
642
|
+
if (!cred.Values)
|
|
643
|
+
throw new Error('GrowthZone credential has no Values JSON');
|
|
644
|
+
return this.ValidateConfig(JSON.parse(cred.Values));
|
|
645
|
+
}
|
|
646
|
+
/** Validates and applies defaults to the config. */
|
|
647
|
+
ValidateConfig(raw) {
|
|
648
|
+
if (!raw || typeof raw !== 'object') {
|
|
649
|
+
throw new Error('GrowthZone configuration is not a valid object');
|
|
650
|
+
}
|
|
651
|
+
const get = (key) => {
|
|
652
|
+
const obj = raw;
|
|
653
|
+
const lower = key.toLowerCase();
|
|
654
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
655
|
+
if (k.toLowerCase() === lower && typeof v === 'string')
|
|
656
|
+
return v;
|
|
657
|
+
}
|
|
658
|
+
return undefined;
|
|
659
|
+
};
|
|
660
|
+
const getNum = (key) => {
|
|
661
|
+
const obj = raw;
|
|
662
|
+
const lower = key.toLowerCase();
|
|
663
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
664
|
+
if (k.toLowerCase() === lower && typeof v === 'number')
|
|
665
|
+
return v;
|
|
666
|
+
}
|
|
667
|
+
return undefined;
|
|
668
|
+
};
|
|
669
|
+
const tenant = get('tenant') ?? get('subdomain');
|
|
670
|
+
const apiKey = get('apikey') ?? get('api_key') ?? get('key');
|
|
671
|
+
const baseUrlOverride = get('baseurloverride') ?? get('base_url_override') ?? get('baseurl');
|
|
672
|
+
if (!tenant && !baseUrlOverride) {
|
|
673
|
+
throw new Error('GrowthZone configuration missing required field: Tenant (or BaseUrlOverride)');
|
|
674
|
+
}
|
|
675
|
+
if (!apiKey) {
|
|
676
|
+
throw new Error('GrowthZone configuration missing required field: ApiKey');
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
Tenant: tenant ?? '',
|
|
680
|
+
ApiKey: apiKey,
|
|
681
|
+
BaseUrlOverride: baseUrlOverride,
|
|
682
|
+
MaxRetries: getNum('maxretries') ?? DEFAULT_MAX_RETRIES,
|
|
683
|
+
RequestTimeoutMs: getNum('requesttimeoutms') ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
684
|
+
MinRequestIntervalMs: getNum('minrequestintervalms') ?? DEFAULT_MIN_REQUEST_INTERVAL_MS,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Resolves the base URL from either an override or the tenant subdomain.
|
|
689
|
+
* Always returns a URL ending in `/api` with no trailing slash.
|
|
690
|
+
*/
|
|
691
|
+
ResolveBaseUrl(config) {
|
|
692
|
+
if (config.BaseUrlOverride) {
|
|
693
|
+
const trimmed = config.BaseUrlOverride.replace(/\/+$/, '');
|
|
694
|
+
return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
|
|
695
|
+
}
|
|
696
|
+
return `https://${config.Tenant}.growthzoneapp.com/api`;
|
|
697
|
+
}
|
|
698
|
+
/** Throttle to respect GrowthZone's recommended minimum request interval. */
|
|
699
|
+
async ThrottleIfNeeded(minIntervalMs) {
|
|
700
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
701
|
+
if (elapsed < minIntervalMs) {
|
|
702
|
+
await this.Sleep(minIntervalMs - elapsed);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** Exponential backoff delay for retry attempts. */
|
|
706
|
+
BackoffDelay(attempt) {
|
|
707
|
+
return Math.min(2_000 * Math.pow(2, attempt), 30_000);
|
|
708
|
+
}
|
|
709
|
+
/** Checks whether an error is transient (network/timeout). */
|
|
710
|
+
IsTransientNetworkError(err) {
|
|
711
|
+
if (!(err instanceof Error))
|
|
712
|
+
return false;
|
|
713
|
+
const msg = err.message.toLowerCase();
|
|
714
|
+
return msg.includes('timeout') || msg.includes('abort') ||
|
|
715
|
+
msg.includes('econnreset') || msg.includes('econnrefused') ||
|
|
716
|
+
msg.includes('fetch failed');
|
|
717
|
+
}
|
|
718
|
+
/** Builds the normalized RESTResponse from a fetch Response. */
|
|
719
|
+
async BuildRESTResponse(response) {
|
|
720
|
+
const headers = {};
|
|
721
|
+
response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
|
|
722
|
+
const text = await response.text();
|
|
723
|
+
let body = null;
|
|
724
|
+
if (text.length > 0) {
|
|
725
|
+
try {
|
|
726
|
+
body = JSON.parse(text);
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
body = text;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return { Status: response.status, Body: body, Headers: headers };
|
|
733
|
+
}
|
|
734
|
+
/** Promise-wrapped setTimeout. */
|
|
735
|
+
Sleep(ms) {
|
|
736
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
GrowthZoneConnector = __decorate([
|
|
740
|
+
RegisterClass(BaseIntegrationConnector, 'GrowthZoneConnector')
|
|
741
|
+
], GrowthZoneConnector);
|
|
742
|
+
export { GrowthZoneConnector };
|
|
743
|
+
/** Tree-shaking prevention function — import and call from module entry point */
|
|
744
|
+
export function LoadGrowthZoneConnector() { }
|
|
745
|
+
//# sourceMappingURL=GrowthZoneConnector.js.map
|