@memberjunction/integration-connectors 5.51.0 → 6.1.0-edge.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/LICENSE +7 -0
- package/README.md +109 -277
- package/dist/index.d.ts +0 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -38
- package/dist/index.js.map +1 -1
- package/package.json +14 -19
- package/dist/AptifyConnector.d.ts +0 -95
- package/dist/AptifyConnector.d.ts.map +0 -1
- package/dist/AptifyConnector.js +0 -760
- package/dist/AptifyConnector.js.map +0 -1
- package/dist/BlackbaudConnector.d.ts +0 -64
- package/dist/BlackbaudConnector.d.ts.map +0 -1
- package/dist/BlackbaudConnector.js +0 -916
- package/dist/BlackbaudConnector.js.map +0 -1
- package/dist/ConstantContactConnector.d.ts +0 -137
- package/dist/ConstantContactConnector.d.ts.map +0 -1
- package/dist/ConstantContactConnector.js +0 -663
- package/dist/ConstantContactConnector.js.map +0 -1
- package/dist/CventConnector.d.ts +0 -146
- package/dist/CventConnector.d.ts.map +0 -1
- package/dist/CventConnector.js +0 -540
- package/dist/CventConnector.js.map +0 -1
- package/dist/DynamicsDataverseConnector.d.ts +0 -296
- package/dist/DynamicsDataverseConnector.d.ts.map +0 -1
- package/dist/DynamicsDataverseConnector.js +0 -790
- package/dist/DynamicsDataverseConnector.js.map +0 -1
- package/dist/FileFeedConnector.d.ts +0 -64
- package/dist/FileFeedConnector.d.ts.map +0 -1
- package/dist/FileFeedConnector.js +0 -190
- package/dist/FileFeedConnector.js.map +0 -1
- package/dist/FontevaConnector.d.ts +0 -153
- package/dist/FontevaConnector.d.ts.map +0 -1
- package/dist/FontevaConnector.js +0 -421
- package/dist/FontevaConnector.js.map +0 -1
- package/dist/GrowthZoneConnector.d.ts +0 -183
- package/dist/GrowthZoneConnector.d.ts.map +0 -1
- package/dist/GrowthZoneConnector.js +0 -702
- package/dist/GrowthZoneConnector.js.map +0 -1
- package/dist/HivebriteConnector.d.ts +0 -151
- package/dist/HivebriteConnector.d.ts.map +0 -1
- package/dist/HivebriteConnector.js +0 -533
- package/dist/HivebriteConnector.js.map +0 -1
- package/dist/HubSpotConnector.d.ts +0 -523
- package/dist/HubSpotConnector.d.ts.map +0 -1
- package/dist/HubSpotConnector.js +0 -3110
- package/dist/HubSpotConnector.js.map +0 -1
- package/dist/IMISConnector.d.ts +0 -171
- package/dist/IMISConnector.d.ts.map +0 -1
- package/dist/IMISConnector.js +0 -695
- package/dist/IMISConnector.js.map +0 -1
- package/dist/MJToMJConnector.d.ts +0 -279
- package/dist/MJToMJConnector.d.ts.map +0 -1
- package/dist/MJToMJConnector.js +0 -893
- package/dist/MJToMJConnector.js.map +0 -1
- package/dist/MagnetMailConnector.d.ts +0 -163
- package/dist/MagnetMailConnector.d.ts.map +0 -1
- package/dist/MagnetMailConnector.js +0 -823
- package/dist/MagnetMailConnector.js.map +0 -1
- package/dist/MailchimpConnector.d.ts +0 -197
- package/dist/MailchimpConnector.d.ts.map +0 -1
- package/dist/MailchimpConnector.js +0 -791
- package/dist/MailchimpConnector.js.map +0 -1
- package/dist/MemberSuiteConnector.d.ts +0 -185
- package/dist/MemberSuiteConnector.d.ts.map +0 -1
- package/dist/MemberSuiteConnector.js +0 -762
- package/dist/MemberSuiteConnector.js.map +0 -1
- package/dist/NeonCRMConnector.d.ts +0 -273
- package/dist/NeonCRMConnector.d.ts.map +0 -1
- package/dist/NeonCRMConnector.js +0 -1039
- package/dist/NeonCRMConnector.js.map +0 -1
- package/dist/NetForumConnector.d.ts +0 -170
- package/dist/NetForumConnector.d.ts.map +0 -1
- package/dist/NetForumConnector.js +0 -778
- package/dist/NetForumConnector.js.map +0 -1
- package/dist/NetSuiteConnector.d.ts +0 -136
- package/dist/NetSuiteConnector.d.ts.map +0 -1
- package/dist/NetSuiteConnector.js +0 -703
- package/dist/NetSuiteConnector.js.map +0 -1
- package/dist/NimbleAMSConnector.d.ts +0 -142
- package/dist/NimbleAMSConnector.d.ts.map +0 -1
- package/dist/NimbleAMSConnector.js +0 -703
- package/dist/NimbleAMSConnector.js.map +0 -1
- package/dist/NoviConnector.d.ts +0 -178
- package/dist/NoviConnector.d.ts.map +0 -1
- package/dist/NoviConnector.js +0 -566
- package/dist/NoviConnector.js.map +0 -1
- package/dist/ORCIDConnector.d.ts +0 -207
- package/dist/ORCIDConnector.d.ts.map +0 -1
- package/dist/ORCIDConnector.js +0 -708
- package/dist/ORCIDConnector.js.map +0 -1
- package/dist/OpenWaterConnector.d.ts +0 -173
- package/dist/OpenWaterConnector.d.ts.map +0 -1
- package/dist/OpenWaterConnector.js +0 -893
- package/dist/OpenWaterConnector.js.map +0 -1
- package/dist/PathLMSConnector.d.ts +0 -395
- package/dist/PathLMSConnector.d.ts.map +0 -1
- package/dist/PathLMSConnector.js +0 -1281
- package/dist/PathLMSConnector.js.map +0 -1
- package/dist/PheedLoopConnector.d.ts +0 -119
- package/dist/PheedLoopConnector.d.ts.map +0 -1
- package/dist/PheedLoopConnector.js +0 -429
- package/dist/PheedLoopConnector.js.map +0 -1
- package/dist/PropFuelConnector.d.ts +0 -153
- package/dist/PropFuelConnector.d.ts.map +0 -1
- package/dist/PropFuelConnector.js +0 -450
- package/dist/PropFuelConnector.js.map +0 -1
- package/dist/QuickBooksConnector.d.ts +0 -92
- package/dist/QuickBooksConnector.d.ts.map +0 -1
- package/dist/QuickBooksConnector.js +0 -713
- package/dist/QuickBooksConnector.js.map +0 -1
- package/dist/RasaConnector.d.ts +0 -152
- package/dist/RasaConnector.d.ts.map +0 -1
- package/dist/RasaConnector.js +0 -929
- package/dist/RasaConnector.js.map +0 -1
- package/dist/Reach360Connector.d.ts +0 -175
- package/dist/Reach360Connector.d.ts.map +0 -1
- package/dist/Reach360Connector.js +0 -796
- package/dist/Reach360Connector.js.map +0 -1
- package/dist/RelationalDBConnector.d.ts +0 -108
- package/dist/RelationalDBConnector.d.ts.map +0 -1
- package/dist/RelationalDBConnector.js +0 -228
- package/dist/RelationalDBConnector.js.map +0 -1
- package/dist/RhythmConnector.d.ts +0 -188
- package/dist/RhythmConnector.d.ts.map +0 -1
- package/dist/RhythmConnector.js +0 -784
- package/dist/RhythmConnector.js.map +0 -1
- package/dist/SageIntacctConnector.d.ts +0 -337
- package/dist/SageIntacctConnector.d.ts.map +0 -1
- package/dist/SageIntacctConnector.js +0 -2081
- package/dist/SageIntacctConnector.js.map +0 -1
- package/dist/SalesforceConnector.d.ts +0 -461
- package/dist/SalesforceConnector.d.ts.map +0 -1
- package/dist/SalesforceConnector.js +0 -1862
- package/dist/SalesforceConnector.js.map +0 -1
- package/dist/SharePointConnector.d.ts +0 -188
- package/dist/SharePointConnector.d.ts.map +0 -1
- package/dist/SharePointConnector.js +0 -716
- package/dist/SharePointConnector.js.map +0 -1
- package/dist/WicketConnector.d.ts +0 -252
- package/dist/WicketConnector.d.ts.map +0 -1
- package/dist/WicketConnector.js +0 -938
- package/dist/WicketConnector.js.map +0 -1
- package/dist/WildApricotConnector.d.ts +0 -201
- package/dist/WildApricotConnector.d.ts.map +0 -1
- package/dist/WildApricotConnector.js +0 -1129
- package/dist/WildApricotConnector.js.map +0 -1
- package/dist/YourMembershipConnector.d.ts +0 -349
- package/dist/YourMembershipConnector.d.ts.map +0 -1
- package/dist/YourMembershipConnector.js +0 -4062
- package/dist/YourMembershipConnector.js.map +0 -1
- package/dist/generate-integration-actions.d.ts +0 -23
- package/dist/generate-integration-actions.d.ts.map +0 -1
- package/dist/generate-integration-actions.js +0 -201
- package/dist/generate-integration-actions.js.map +0 -1
|
@@ -1,1129 +0,0 @@
|
|
|
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
|
-
const WA_OAUTH_TOKEN_URL = 'https://oauth.wildapricot.org/auth/token';
|
|
12
|
-
const WA_API_HOST = 'https://api.wildapricot.org';
|
|
13
|
-
const DEFAULT_API_VERSION = 'v2.3';
|
|
14
|
-
/** Refresh access token 60s before hard expiry to avoid race conditions. */
|
|
15
|
-
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
|
|
16
|
-
const DEFAULT_MAX_RETRIES = 4;
|
|
17
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
18
|
-
/** 60 req/min documented baseline → ~1.1s floor. */
|
|
19
|
-
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 1100;
|
|
20
|
-
const DEFAULT_ASYNC_POLL_INTERVAL_MS = 2000;
|
|
21
|
-
const DEFAULT_ASYNC_POLL_TIMEOUT_MS = 120000;
|
|
22
|
-
const DEFAULT_PAGE_SIZE = 100;
|
|
23
|
-
/**
|
|
24
|
-
* Maps the ApiType string used in /contactfields responses to a MemberJunction
|
|
25
|
-
* field DataType. Wild Apricot's custom field type taxonomy is a closed set
|
|
26
|
-
* documented in the Admin API.
|
|
27
|
-
*/
|
|
28
|
-
const WA_CUSTOM_FIELD_TYPE_MAP = {
|
|
29
|
-
'String': 'nvarchar',
|
|
30
|
-
'Text': 'nvarchar',
|
|
31
|
-
'MultilineText': 'nvarchar',
|
|
32
|
-
'Number': 'decimal',
|
|
33
|
-
'Decimal': 'decimal',
|
|
34
|
-
'Integer': 'int',
|
|
35
|
-
'Boolean': 'bit',
|
|
36
|
-
'Date': 'datetime',
|
|
37
|
-
'DateTime': 'datetime',
|
|
38
|
-
'Email': 'nvarchar',
|
|
39
|
-
'Phone': 'nvarchar',
|
|
40
|
-
'Picture': 'nvarchar',
|
|
41
|
-
'Document': 'nvarchar',
|
|
42
|
-
'Choice': 'nvarchar',
|
|
43
|
-
'MultipleChoice': 'nvarchar',
|
|
44
|
-
'RadioButtons': 'nvarchar',
|
|
45
|
-
'DropDown': 'nvarchar',
|
|
46
|
-
'ExtraChargeCalculation': 'decimal',
|
|
47
|
-
'Rules': 'nvarchar',
|
|
48
|
-
'Section': 'nvarchar',
|
|
49
|
-
};
|
|
50
|
-
// ─── Static Action Metadata ───────────────────────────────────────────
|
|
51
|
-
/**
|
|
52
|
-
* Static catalog of Wild Apricot admin API objects used for action generation.
|
|
53
|
-
* Mirrors the runtime metadata in `metadata/integrations/.wild-apricot.json`
|
|
54
|
-
* but is maintained here so action generation does not require a live
|
|
55
|
-
* connection. The custom-fields discovery layer is dynamic and layered on top
|
|
56
|
-
* of the Contacts object at runtime.
|
|
57
|
-
*/
|
|
58
|
-
const WILD_APRICOT_OBJECTS = [
|
|
59
|
-
{
|
|
60
|
-
Name: 'Contacts', DisplayName: 'Contacts', SupportsWrite: true,
|
|
61
|
-
Description: 'Contacts including members, prospects, and staff. Rich custom field support via FieldValues.',
|
|
62
|
-
Fields: [
|
|
63
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Unique contact identifier' },
|
|
64
|
-
{ Name: 'FirstName', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Shortcut to FirstName system field' },
|
|
65
|
-
{ Name: 'LastName', DisplayName: 'Last Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Shortcut to LastName system field' },
|
|
66
|
-
{ Name: 'Email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Primary email — natural key' },
|
|
67
|
-
{ Name: 'Organization', DisplayName: 'Organization', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Organization name' },
|
|
68
|
-
{ Name: 'DisplayName', DisplayName: 'Display Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Computed display name' },
|
|
69
|
-
{ Name: 'MembershipLevel', DisplayName: 'Membership Level', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Linked membership level reference' },
|
|
70
|
-
{ Name: 'MembershipEnabled', DisplayName: 'Membership Enabled', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Whether contact is a member' },
|
|
71
|
-
{ Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Membership status' },
|
|
72
|
-
{ Name: 'ProfileLastUpdated', DisplayName: 'Profile Last Updated', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Watermark field for incremental sync' },
|
|
73
|
-
],
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
Name: 'MembershipLevels', DisplayName: 'Membership Levels', SupportsWrite: false,
|
|
77
|
-
Description: 'Membership level catalog — tiers, fees, renewal cadence.',
|
|
78
|
-
Fields: [
|
|
79
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Unique membership level ID' },
|
|
80
|
-
{ Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Level name' },
|
|
81
|
-
{ Name: 'Description', DisplayName: 'Description', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Level description' },
|
|
82
|
-
{ Name: 'Type', DisplayName: 'Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Individual or Bundle' },
|
|
83
|
-
{ Name: 'MembershipFee', DisplayName: 'Membership Fee', Type: 'decimal', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Base fee' },
|
|
84
|
-
{ Name: 'PublicCanApply', DisplayName: 'Public Can Apply', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Self-apply flag' },
|
|
85
|
-
{ Name: 'BundleMembersLimit', DisplayName: 'Bundle Members Limit', Type: 'integer', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Seats for bundle tiers' },
|
|
86
|
-
],
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
Name: 'MemberGroups', DisplayName: 'Member Groups', SupportsWrite: false,
|
|
90
|
-
Description: 'Ad-hoc member groups with contact membership lists.',
|
|
91
|
-
Fields: [
|
|
92
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Group ID' },
|
|
93
|
-
{ Name: 'ContactsCount', DisplayName: 'Contacts Count', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Count of members' },
|
|
94
|
-
{ Name: 'ContactIds', DisplayName: 'Contact IDs', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Member contact IDs (array)' },
|
|
95
|
-
],
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
Name: 'Events', DisplayName: 'Events', SupportsWrite: true,
|
|
99
|
-
Description: 'Event catalog with dates, locations, registration settings, and tags.',
|
|
100
|
-
Fields: [
|
|
101
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Event ID' },
|
|
102
|
-
{ Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event title' },
|
|
103
|
-
{ Name: 'EventType', DisplayName: 'Event Type', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Simple (RSVP) or Regular' },
|
|
104
|
-
{ Name: 'StartDate', DisplayName: 'Start Date', Type: 'datetime', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Scheduled start' },
|
|
105
|
-
{ Name: 'EndDate', DisplayName: 'End Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Scheduled end' },
|
|
106
|
-
{ Name: 'Location', DisplayName: 'Location', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Location text' },
|
|
107
|
-
{ Name: 'RegistrationEnabled', DisplayName: 'Registration Enabled', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Whether registration is open' },
|
|
108
|
-
{ Name: 'RegistrationsLimit', DisplayName: 'Registrations Limit', Type: 'integer', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Max registrations' },
|
|
109
|
-
],
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
Name: 'EventRegistrations', DisplayName: 'Event Registrations', SupportsWrite: true,
|
|
113
|
-
Description: 'Per-event registrations tying a Contact to an Event + RegistrationType.',
|
|
114
|
-
Fields: [
|
|
115
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Registration ID' },
|
|
116
|
-
{ Name: 'Event', DisplayName: 'Event', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Linked event reference' },
|
|
117
|
-
{ Name: 'Contact', DisplayName: 'Contact', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Linked contact reference' },
|
|
118
|
-
{ Name: 'RegistrationTypeId', DisplayName: 'Registration Type ID', Type: 'integer', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Registration type FK' },
|
|
119
|
-
{ Name: 'IsCheckedIn', DisplayName: 'Is Checked In', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Check-in flag' },
|
|
120
|
-
{ Name: 'RegistrationDate', DisplayName: 'Registration Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
121
|
-
{ Name: 'Memo', DisplayName: 'Memo', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Internal notes' },
|
|
122
|
-
],
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
Name: 'EventRegistrationTypes', DisplayName: 'Event Registration Types', SupportsWrite: true,
|
|
126
|
-
Description: 'Event-specific registration type definitions with pricing and availability.',
|
|
127
|
-
Fields: [
|
|
128
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Registration type ID' },
|
|
129
|
-
{ Name: 'EventId', DisplayName: 'Event ID', Type: 'integer', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Parent event' },
|
|
130
|
-
{ Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Display name' },
|
|
131
|
-
{ Name: 'IsEnabled', DisplayName: 'Is Enabled', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Enabled flag' },
|
|
132
|
-
{ Name: 'BasePrice', DisplayName: 'Base Price', Type: 'decimal', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Base price' },
|
|
133
|
-
{ Name: 'GuestPrice', DisplayName: 'Guest Price', Type: 'decimal', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Per-guest price' },
|
|
134
|
-
{ Name: 'AvailableFrom', DisplayName: 'Available From', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Start of availability window' },
|
|
135
|
-
{ Name: 'AvailableThrough', DisplayName: 'Available Through', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'End of availability window' },
|
|
136
|
-
],
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
Name: 'Invoices', DisplayName: 'Invoices', SupportsWrite: true,
|
|
140
|
-
Description: 'Invoices with line items, totals, and paid amounts.',
|
|
141
|
-
Fields: [
|
|
142
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Invoice ID' },
|
|
143
|
-
{ Name: 'DocumentNumber', DisplayName: 'Document Number', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'User-visible invoice number' },
|
|
144
|
-
{ Name: 'IsPaid', DisplayName: 'Is Paid', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Fully paid flag' },
|
|
145
|
-
{ Name: 'PaidAmount', DisplayName: 'Paid Amount', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Amount paid so far' },
|
|
146
|
-
{ Name: 'OrderType', DisplayName: 'Order Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Source order type' },
|
|
147
|
-
{ Name: 'Memo', DisplayName: 'Memo', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Internal memo' },
|
|
148
|
-
{ Name: 'PublicMemo', DisplayName: 'Public Memo', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Public memo' },
|
|
149
|
-
{ Name: 'VoidedDate', DisplayName: 'Voided Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Void timestamp' },
|
|
150
|
-
],
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
Name: 'Payments', DisplayName: 'Payments', SupportsWrite: true,
|
|
154
|
-
Description: 'Payments linked to invoices and contacts.',
|
|
155
|
-
Fields: [
|
|
156
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Payment ID' },
|
|
157
|
-
{ Name: 'Value', DisplayName: 'Value', Type: 'decimal', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Payment amount' },
|
|
158
|
-
{ Name: 'DocumentDate', DisplayName: 'Document Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Payment date' },
|
|
159
|
-
{ Name: 'Contact', DisplayName: 'Contact', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Linked contact' },
|
|
160
|
-
{ Name: 'Tender', DisplayName: 'Tender', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Linked tender' },
|
|
161
|
-
{ Name: 'Comment', DisplayName: 'Comment', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Internal note' },
|
|
162
|
-
{ Name: 'PublicComment', DisplayName: 'Public Comment', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Public note' },
|
|
163
|
-
{ Name: 'PaymentType', DisplayName: 'Payment Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Payment type' },
|
|
164
|
-
],
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
Name: 'Refunds', DisplayName: 'Refunds', SupportsWrite: true,
|
|
168
|
-
Description: 'Refunds issued against payments.',
|
|
169
|
-
Fields: [
|
|
170
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Refund ID' },
|
|
171
|
-
{ Name: 'Tender', DisplayName: 'Tender', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Tender reference' },
|
|
172
|
-
{ Name: 'Comment', DisplayName: 'Comment', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Internal comment' },
|
|
173
|
-
{ Name: 'PublicComment', DisplayName: 'Public Comment', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Public-facing comment' },
|
|
174
|
-
{ Name: 'SettledValue', DisplayName: 'Settled Value', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Previously settled amount' },
|
|
175
|
-
],
|
|
176
|
-
},
|
|
177
|
-
{
|
|
178
|
-
Name: 'Donations', DisplayName: 'Donations', SupportsWrite: true,
|
|
179
|
-
Description: 'Donation transactions with donor, fund, and amount details.',
|
|
180
|
-
Fields: [
|
|
181
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Donation ID' },
|
|
182
|
-
{ Name: 'Value', DisplayName: 'Value', Type: 'decimal', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Donation amount' },
|
|
183
|
-
{ Name: 'DonationDate', DisplayName: 'Donation Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Donation date' },
|
|
184
|
-
{ Name: 'FirstName', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Donor first name' },
|
|
185
|
-
{ Name: 'LastName', DisplayName: 'Last Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Donor last name' },
|
|
186
|
-
{ Name: 'Email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Donor email' },
|
|
187
|
-
{ Name: 'Organization', DisplayName: 'Organization', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Donor organization' },
|
|
188
|
-
{ Name: 'Type', DisplayName: 'Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Donation type' },
|
|
189
|
-
{ Name: 'Comment', DisplayName: 'Comment', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Internal note' },
|
|
190
|
-
{ Name: 'PublicComment', DisplayName: 'Public Comment', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Public-facing note' },
|
|
191
|
-
],
|
|
192
|
-
},
|
|
193
|
-
{
|
|
194
|
-
Name: 'Tenders', DisplayName: 'Tenders', SupportsWrite: true,
|
|
195
|
-
Description: 'Tender (payment method) definitions — cash, check, credit card, etc.',
|
|
196
|
-
Fields: [
|
|
197
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Tender ID' },
|
|
198
|
-
{ Name: 'DisplayPosition', DisplayName: 'Display Position', Type: 'integer', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Display sort order' },
|
|
199
|
-
{ Name: 'IsCustom', DisplayName: 'Is Custom', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Admin-created flag' },
|
|
200
|
-
],
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
Name: 'ContactFields', DisplayName: 'Contact Fields', SupportsWrite: true,
|
|
204
|
-
Description: 'Definitions of standard and custom fields available on Contacts. Drives dynamic custom-field discovery.',
|
|
205
|
-
Fields: [
|
|
206
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Field definition ID' },
|
|
207
|
-
{ Name: 'SystemCode', DisplayName: 'System Code', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Stable system code' },
|
|
208
|
-
{ Name: 'FieldName', DisplayName: 'Field Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Human-readable field name' },
|
|
209
|
-
{ Name: 'FieldType', DisplayName: 'Field Type', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Data type (Text, Number, Choice, etc.)' },
|
|
210
|
-
{ Name: 'MemberOnly', DisplayName: 'Member Only', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Whether field is member-only' },
|
|
211
|
-
],
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
Name: 'DonationFields', DisplayName: 'Donation Fields', SupportsWrite: true,
|
|
215
|
-
Description: 'Custom field definitions for donations.',
|
|
216
|
-
Fields: [
|
|
217
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Field ID' },
|
|
218
|
-
{ Name: 'FieldType', DisplayName: 'Field Type', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Data type' },
|
|
219
|
-
],
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
Name: 'Bundles', DisplayName: 'Bundles', SupportsWrite: false,
|
|
223
|
-
Description: 'Bundle memberships grouping multiple sub-members under a primary contact.',
|
|
224
|
-
Fields: [
|
|
225
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Bundle ID' },
|
|
226
|
-
{ Name: 'Email', DisplayName: 'Administrator Email', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Bundle admin email' },
|
|
227
|
-
{ Name: 'ParticipantsCount', DisplayName: 'Participants Count', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Number of members' },
|
|
228
|
-
{ Name: 'SpacesLeft', DisplayName: 'Spaces Left', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Remaining capacity' },
|
|
229
|
-
],
|
|
230
|
-
},
|
|
231
|
-
{
|
|
232
|
-
Name: 'SentEmails', DisplayName: 'Sent Emails', SupportsWrite: false,
|
|
233
|
-
Description: 'History of emails sent through Wild Apricot with delivery metrics.',
|
|
234
|
-
Fields: [
|
|
235
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Email ID' },
|
|
236
|
-
{ Name: 'SentDate', DisplayName: 'Sent Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Send start timestamp' },
|
|
237
|
-
{ Name: 'Subject', DisplayName: 'Subject', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Email subject' },
|
|
238
|
-
{ Name: 'RecipientCount', DisplayName: 'Recipient Count', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total recipients' },
|
|
239
|
-
{ Name: 'SuccessfullySentCount', DisplayName: 'Successfully Sent', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Successfully delivered count' },
|
|
240
|
-
{ Name: 'ReadCount', DisplayName: 'Read Count', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Opened count' },
|
|
241
|
-
{ Name: 'FailedCount', DisplayName: 'Failed Count', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Failed delivery count' },
|
|
242
|
-
],
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
Name: 'EmailDrafts', DisplayName: 'Email Drafts', SupportsWrite: false,
|
|
246
|
-
Description: 'Draft emails staged for sending, including scheduled-send metadata.',
|
|
247
|
-
Fields: [
|
|
248
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Draft ID' },
|
|
249
|
-
{ Name: 'CreatedDate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Created at' },
|
|
250
|
-
{ Name: 'LastChangedDate', DisplayName: 'Last Changed', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last edit timestamp' },
|
|
251
|
-
{ Name: 'Subject', DisplayName: 'Subject', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Draft subject' },
|
|
252
|
-
{ Name: 'IsScheduled', DisplayName: 'Is Scheduled', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Scheduled-send flag' },
|
|
253
|
-
{ Name: 'ScheduledDate', DisplayName: 'Scheduled Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'When draft will send' },
|
|
254
|
-
],
|
|
255
|
-
},
|
|
256
|
-
{
|
|
257
|
-
Name: 'AuditLogItems', DisplayName: 'Audit Log Items', SupportsWrite: false,
|
|
258
|
-
Description: 'Audit log entries capturing admin + member actions across the tenant.',
|
|
259
|
-
Fields: [
|
|
260
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Audit log ID' },
|
|
261
|
-
{ Name: 'Timestamp', DisplayName: 'Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Event timestamp' },
|
|
262
|
-
{ Name: 'FirstName', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Actor first name' },
|
|
263
|
-
{ Name: 'LastName', DisplayName: 'Last Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Actor last name' },
|
|
264
|
-
{ Name: 'Email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Actor email' },
|
|
265
|
-
{ Name: 'Message', DisplayName: 'Message', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Log message' },
|
|
266
|
-
{ Name: 'Severity', DisplayName: 'Severity', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Severity level' },
|
|
267
|
-
{ Name: 'OrderType', DisplayName: 'Order Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Origin of audit event' },
|
|
268
|
-
],
|
|
269
|
-
},
|
|
270
|
-
{
|
|
271
|
-
Name: 'SavedSearches', DisplayName: 'Saved Searches', SupportsWrite: false,
|
|
272
|
-
Description: 'Saved contact searches with computed contact ID lists.',
|
|
273
|
-
Fields: [
|
|
274
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'integer', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Saved search ID' },
|
|
275
|
-
{ Name: 'ContactIds', DisplayName: 'Contact IDs', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Matching contact IDs' },
|
|
276
|
-
],
|
|
277
|
-
},
|
|
278
|
-
{
|
|
279
|
-
Name: 'StoreProducts', DisplayName: 'Store Products', SupportsWrite: false,
|
|
280
|
-
Description: 'Storefront product catalog with pricing, stock, and option/variant metadata.',
|
|
281
|
-
Fields: [
|
|
282
|
-
{ Name: 'id', DisplayName: 'ID', Type: 'integer', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Product ID' },
|
|
283
|
-
{ Name: 'title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Product title' },
|
|
284
|
-
{ Name: 'description', DisplayName: 'Description', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Description' },
|
|
285
|
-
{ Name: 'price', DisplayName: 'Price', Type: 'decimal', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Price object (value + currency)' },
|
|
286
|
-
{ Name: 'status', DisplayName: 'Status', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Product status' },
|
|
287
|
-
{ Name: 'type', DisplayName: 'Type', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Product type' },
|
|
288
|
-
{ Name: 'stock', DisplayName: 'Stock', Type: 'integer', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Inventory count' },
|
|
289
|
-
{ Name: 'trackInventory', DisplayName: 'Track Inventory', Type: 'boolean', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Whether inventory is tracked' },
|
|
290
|
-
{ Name: 'outOfStock', DisplayName: 'Out Of Stock', Type: 'boolean', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Out-of-stock flag' },
|
|
291
|
-
],
|
|
292
|
-
},
|
|
293
|
-
];
|
|
294
|
-
/**
|
|
295
|
-
* Maps the API object name to its list path relative to `/accounts/{accountId}`.
|
|
296
|
-
* Keyed lowercase for case-insensitive lookup.
|
|
297
|
-
*/
|
|
298
|
-
const WA_API_PATHS = {
|
|
299
|
-
contacts: 'contacts',
|
|
300
|
-
contactfields: 'contactfields',
|
|
301
|
-
membershiplevels: 'membershiplevels',
|
|
302
|
-
membergroups: 'membergroups',
|
|
303
|
-
events: 'events',
|
|
304
|
-
eventregistrations: 'eventregistrations',
|
|
305
|
-
eventregistrationtypes: 'EventRegistrationTypes',
|
|
306
|
-
invoices: 'invoices',
|
|
307
|
-
payments: 'payments',
|
|
308
|
-
refunds: 'refunds',
|
|
309
|
-
donations: 'donations',
|
|
310
|
-
donationfields: 'donationfields',
|
|
311
|
-
tenders: 'tenders',
|
|
312
|
-
bundles: 'bundles',
|
|
313
|
-
sentemails: 'SentEmails',
|
|
314
|
-
emaildrafts: 'EmailDrafts',
|
|
315
|
-
auditlogitems: 'auditLogItems',
|
|
316
|
-
savedsearches: 'savedsearches',
|
|
317
|
-
storeproducts: 'store/products',
|
|
318
|
-
};
|
|
319
|
-
/**
|
|
320
|
-
* Maps the API object name to the response envelope key that contains the
|
|
321
|
-
* record array. Null means the response body IS the array (root array).
|
|
322
|
-
*/
|
|
323
|
-
const WA_RESPONSE_DATA_KEYS = {
|
|
324
|
-
contacts: 'Contacts',
|
|
325
|
-
contactfields: null,
|
|
326
|
-
membershiplevels: null,
|
|
327
|
-
membergroups: null,
|
|
328
|
-
events: 'Events',
|
|
329
|
-
eventregistrations: null,
|
|
330
|
-
eventregistrationtypes: null,
|
|
331
|
-
invoices: 'Invoices',
|
|
332
|
-
payments: 'Payments',
|
|
333
|
-
refunds: 'Refunds',
|
|
334
|
-
donations: 'Donations',
|
|
335
|
-
donationfields: null,
|
|
336
|
-
tenders: 'Tenders',
|
|
337
|
-
bundles: null,
|
|
338
|
-
sentemails: null,
|
|
339
|
-
emaildrafts: null,
|
|
340
|
-
auditlogitems: 'Items',
|
|
341
|
-
savedsearches: null,
|
|
342
|
-
storeproducts: null,
|
|
343
|
-
};
|
|
344
|
-
/**
|
|
345
|
-
* Maps the API object name to an incremental-sync configuration.
|
|
346
|
-
* Wild Apricot's filter syntax uses display names with spaces in quotes,
|
|
347
|
-
* e.g. `$filter='Profile last updated' ge '<iso>'` on Contacts.
|
|
348
|
-
*/
|
|
349
|
-
const WA_WATERMARK_CONFIG = {
|
|
350
|
-
contacts: { filterField: 'Profile last updated', responseField: 'ProfileLastUpdated' },
|
|
351
|
-
events: { filterField: 'LastUpdated', responseField: 'LastUpdated' },
|
|
352
|
-
invoices: { filterField: 'LastUpdated', responseField: 'LastUpdated' },
|
|
353
|
-
payments: { filterField: 'LastUpdated', responseField: 'LastUpdated' },
|
|
354
|
-
};
|
|
355
|
-
// ─── Connector Implementation ─────────────────────────────────────────
|
|
356
|
-
/**
|
|
357
|
-
* Connector for Wild Apricot (part of Personify / Momentive Software).
|
|
358
|
-
*
|
|
359
|
-
* Authenticates via OAuth 2.0 client_credentials (API Key as Basic auth
|
|
360
|
-
* username). Auto-discovers the Account ID from GET /v2/accounts when not
|
|
361
|
-
* supplied. Handles the cursor-by-offset pagination model (`$top`/`$skip`),
|
|
362
|
-
* the async contact query pattern (`$async=true` + poll), custom-field
|
|
363
|
-
* discovery via `/contactfields`, server-side date filtering with the
|
|
364
|
-
* `$filter` OData syntax, and rate-limit-aware retries.
|
|
365
|
-
*/
|
|
366
|
-
let WildApricotConnector = class WildApricotConnector extends BaseRESTIntegrationConnector {
|
|
367
|
-
constructor() {
|
|
368
|
-
super(...arguments);
|
|
369
|
-
/** Cached auth context (token + discovered account). Invalidated on token expiry or 401. */
|
|
370
|
-
this.authState = null;
|
|
371
|
-
/** Timestamp of the last outbound request, used for throttling. */
|
|
372
|
-
this.lastRequestTime = 0;
|
|
373
|
-
/** Currently-active watermark for FetchChanges, used to inject $filter params. */
|
|
374
|
-
this.currentWatermark = null;
|
|
375
|
-
}
|
|
376
|
-
// ── Capability getters ────────────────────────────────────────────
|
|
377
|
-
get SupportsCreate() { return true; }
|
|
378
|
-
get SupportsUpdate() { return true; }
|
|
379
|
-
get SupportsDelete() { return true; }
|
|
380
|
-
get IntegrationName() { return 'Wild Apricot'; }
|
|
381
|
-
// ── Action generation ─────────────────────────────────────────────
|
|
382
|
-
GetIntegrationObjects() {
|
|
383
|
-
return WILD_APRICOT_OBJECTS;
|
|
384
|
-
}
|
|
385
|
-
GetActionGeneratorConfig() {
|
|
386
|
-
const objects = this.GetIntegrationObjects();
|
|
387
|
-
if (objects.length === 0)
|
|
388
|
-
return null;
|
|
389
|
-
return {
|
|
390
|
-
IntegrationName: 'Wild Apricot',
|
|
391
|
-
CategoryName: 'Wild Apricot',
|
|
392
|
-
IconClass: 'fa-solid fa-apple-whole',
|
|
393
|
-
Objects: objects,
|
|
394
|
-
IncludeSearch: false,
|
|
395
|
-
IncludeList: false,
|
|
396
|
-
CategoryDescription: 'Wild Apricot (Personify) AMS integration actions',
|
|
397
|
-
ParentCategoryName: 'Membership',
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
GetDefaultConfiguration() {
|
|
401
|
-
return {
|
|
402
|
-
DefaultSchemaName: 'Wild Apricot',
|
|
403
|
-
DefaultObjects: [
|
|
404
|
-
{
|
|
405
|
-
SourceObjectName: 'Contacts',
|
|
406
|
-
TargetTableName: 'WildApricot_Contact',
|
|
407
|
-
TargetEntityName: 'WildApricot Contacts',
|
|
408
|
-
SyncEnabled: true,
|
|
409
|
-
FieldMappings: this.GetDefaultFieldMappings('Contacts', 'Contacts'),
|
|
410
|
-
},
|
|
411
|
-
{
|
|
412
|
-
SourceObjectName: 'Events',
|
|
413
|
-
TargetTableName: 'WildApricot_Event',
|
|
414
|
-
TargetEntityName: 'WildApricot Events',
|
|
415
|
-
SyncEnabled: true,
|
|
416
|
-
FieldMappings: this.GetDefaultFieldMappings('Events', 'Events'),
|
|
417
|
-
},
|
|
418
|
-
{
|
|
419
|
-
SourceObjectName: 'EventRegistrations',
|
|
420
|
-
TargetTableName: 'WildApricot_EventRegistration',
|
|
421
|
-
TargetEntityName: 'WildApricot Event Registrations',
|
|
422
|
-
SyncEnabled: true,
|
|
423
|
-
FieldMappings: [],
|
|
424
|
-
},
|
|
425
|
-
],
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
GetDefaultFieldMappings(objectName, _entityName) {
|
|
429
|
-
switch (objectName) {
|
|
430
|
-
case 'Contacts':
|
|
431
|
-
return [
|
|
432
|
-
{ SourceFieldName: 'Id', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
433
|
-
{ SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
|
|
434
|
-
{ SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
|
|
435
|
-
{ SourceFieldName: 'Email', DestinationFieldName: 'Email' },
|
|
436
|
-
{ SourceFieldName: 'Organization', DestinationFieldName: 'CompanyName' },
|
|
437
|
-
];
|
|
438
|
-
case 'Events':
|
|
439
|
-
return [
|
|
440
|
-
{ SourceFieldName: 'Id', DestinationFieldName: 'ExternalID', IsKeyField: true },
|
|
441
|
-
{ SourceFieldName: 'Name', DestinationFieldName: 'Name' },
|
|
442
|
-
{ SourceFieldName: 'StartDate', DestinationFieldName: 'StartDate' },
|
|
443
|
-
{ SourceFieldName: 'EndDate', DestinationFieldName: 'EndDate' },
|
|
444
|
-
{ SourceFieldName: 'Location', DestinationFieldName: 'Location' },
|
|
445
|
-
];
|
|
446
|
-
default:
|
|
447
|
-
return [];
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
// ─── TestConnection ──────────────────────────────────────────────
|
|
451
|
-
/**
|
|
452
|
-
* Verifies connectivity by authenticating (which also discovers the
|
|
453
|
-
* Account ID if needed) and calling `/` on the accounts endpoint to
|
|
454
|
-
* fetch account name/URL for feedback.
|
|
455
|
-
*/
|
|
456
|
-
async TestConnection(companyIntegration, contextUser) {
|
|
457
|
-
try {
|
|
458
|
-
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
459
|
-
const url = `${auth.BaseUrl}`;
|
|
460
|
-
const headers = this.BuildHeaders(auth);
|
|
461
|
-
const resp = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
462
|
-
if (resp.Status < 200 || resp.Status >= 300) {
|
|
463
|
-
return { Success: false, Message: `Wild Apricot TestConnection failed: HTTP ${resp.Status}` };
|
|
464
|
-
}
|
|
465
|
-
const body = resp.Body;
|
|
466
|
-
const name = body?.Name ?? 'Unknown account';
|
|
467
|
-
return {
|
|
468
|
-
Success: true,
|
|
469
|
-
Message: `Successfully connected to Wild Apricot account: ${name}`,
|
|
470
|
-
ServerVersion: `Wild Apricot Admin API ${auth.Config.ApiVersion ?? DEFAULT_API_VERSION}`,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
catch (err) {
|
|
474
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
475
|
-
return { Success: false, Message: `Connection failed: ${message}` };
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
// ─── Discovery ────────────────────────────────────────────────────
|
|
479
|
-
async DiscoverObjects(_companyIntegration, _contextUser) {
|
|
480
|
-
return WILD_APRICOT_OBJECTS.map(obj => ({
|
|
481
|
-
Name: obj.Name,
|
|
482
|
-
Label: obj.DisplayName,
|
|
483
|
-
Description: obj.Description,
|
|
484
|
-
SupportsIncrementalSync: this.supportsIncrementalSync(obj.Name),
|
|
485
|
-
SupportsWrite: obj.SupportsWrite,
|
|
486
|
-
}));
|
|
487
|
-
}
|
|
488
|
-
/**
|
|
489
|
-
* Returns the static fields for the object, merging in any dynamically
|
|
490
|
-
* discovered custom fields for Contacts via GET /contactfields. Custom
|
|
491
|
-
* fields are flagged in their description for downstream consumers.
|
|
492
|
-
*/
|
|
493
|
-
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
494
|
-
const base = this.staticFieldsFor(objectName);
|
|
495
|
-
if (objectName.toLowerCase() !== 'contacts')
|
|
496
|
-
return base;
|
|
497
|
-
try {
|
|
498
|
-
const customFields = await this.discoverContactCustomFields(companyIntegration, contextUser);
|
|
499
|
-
return this.mergeStaticAndCustomFields(base, customFields);
|
|
500
|
-
}
|
|
501
|
-
catch (err) {
|
|
502
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
503
|
-
console.warn(`[WildApricot] Custom field discovery for Contacts failed — returning static catalog only: ${message}`);
|
|
504
|
-
return base;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
staticFieldsFor(objectName) {
|
|
508
|
-
const obj = WILD_APRICOT_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
|
|
509
|
-
if (!obj)
|
|
510
|
-
return [];
|
|
511
|
-
return obj.Fields.map(f => {
|
|
512
|
-
const fkTarget = this.resolveForeignKeyTarget(f.Name);
|
|
513
|
-
return {
|
|
514
|
-
Name: f.Name,
|
|
515
|
-
Label: f.DisplayName,
|
|
516
|
-
Description: f.Description,
|
|
517
|
-
DataType: f.Type,
|
|
518
|
-
IsRequired: f.IsRequired,
|
|
519
|
-
IsUniqueKey: f.IsPrimaryKey,
|
|
520
|
-
IsReadOnly: f.IsReadOnly,
|
|
521
|
-
IsForeignKey: fkTarget != null,
|
|
522
|
-
ForeignKeyTarget: fkTarget,
|
|
523
|
-
};
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
/**
|
|
527
|
-
* Resolves a field name to a sibling object's external name when the field
|
|
528
|
-
* is an implicit foreign key — i.e. it is named `<Sibling>Id` / `<Sibling>_id`
|
|
529
|
-
* / `<Sibling>Ref` AND `<Sibling>` matches another object declared in this
|
|
530
|
-
* connector (singular/plural aware). Provable-only: returns null unless the
|
|
531
|
-
* named sibling is itself a declared WILD_APRICOT_OBJECTS object, so no FK is
|
|
532
|
-
* invented for references whose target this connector does not model.
|
|
533
|
-
*
|
|
534
|
-
* With the current static catalog this resolves exactly one field —
|
|
535
|
-
* EventRegistrationTypes.EventId → Events. (RegistrationTypeId does NOT
|
|
536
|
-
* resolve: no "RegistrationType(s)" object is declared.)
|
|
537
|
-
*/
|
|
538
|
-
resolveForeignKeyTarget(fieldName) {
|
|
539
|
-
const m = /^(.+?)(Id|_id|Ref)$/.exec(fieldName);
|
|
540
|
-
if (!m)
|
|
541
|
-
return null;
|
|
542
|
-
const sibling = m[1].toLowerCase();
|
|
543
|
-
const singular = sibling.endsWith('s') ? sibling.slice(0, -1) : sibling;
|
|
544
|
-
const plural = `${singular}s`;
|
|
545
|
-
const target = WILD_APRICOT_OBJECTS.find(o => {
|
|
546
|
-
const name = o.Name.toLowerCase();
|
|
547
|
-
return name === sibling || name === singular || name === plural;
|
|
548
|
-
});
|
|
549
|
-
return target ? target.Name : null;
|
|
550
|
-
}
|
|
551
|
-
/**
|
|
552
|
-
* Fetches Wild Apricot's contact field definitions and returns one
|
|
553
|
-
* ExternalFieldSchema per contact field, marked `IsCustom` in the
|
|
554
|
-
* description so consumers can recognise them.
|
|
555
|
-
*/
|
|
556
|
-
async discoverContactCustomFields(companyIntegration, contextUser) {
|
|
557
|
-
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
558
|
-
const url = `${auth.BaseUrl}/contactfields`;
|
|
559
|
-
const headers = this.BuildHeaders(auth);
|
|
560
|
-
const resp = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
561
|
-
if (resp.Status < 200 || resp.Status >= 300)
|
|
562
|
-
return [];
|
|
563
|
-
const arr = Array.isArray(resp.Body) ? resp.Body : [];
|
|
564
|
-
return arr
|
|
565
|
-
.filter(f => typeof f['SystemCode'] === 'string')
|
|
566
|
-
.map(f => this.toContactFieldSchema(f));
|
|
567
|
-
}
|
|
568
|
-
toContactFieldSchema(raw) {
|
|
569
|
-
const systemCode = String(raw['SystemCode'] ?? '');
|
|
570
|
-
const fieldName = typeof raw['FieldName'] === 'string' ? raw['FieldName'] : systemCode;
|
|
571
|
-
const apiType = typeof raw['Type'] === 'string' ? raw['Type'] : 'String';
|
|
572
|
-
const dataType = WA_CUSTOM_FIELD_TYPE_MAP[apiType] ?? 'nvarchar';
|
|
573
|
-
const isSystem = raw['IsSystem'] === true;
|
|
574
|
-
return {
|
|
575
|
-
Name: systemCode,
|
|
576
|
-
Label: fieldName,
|
|
577
|
-
Description: isSystem
|
|
578
|
-
? `Wild Apricot standard contact field (${apiType}).`
|
|
579
|
-
: `Wild Apricot CUSTOM contact field (${apiType}). IsCustom=true. Stored inside Contact.FieldValues[] keyed by SystemCode.`,
|
|
580
|
-
DataType: dataType,
|
|
581
|
-
IsRequired: raw['IsRequired'] === true,
|
|
582
|
-
IsUniqueKey: false,
|
|
583
|
-
IsReadOnly: raw['IsSystem'] === true && raw['IsBuiltIn'] === true,
|
|
584
|
-
};
|
|
585
|
-
}
|
|
586
|
-
/**
|
|
587
|
-
* Merges static fields with discovered contact fields. Static fields win
|
|
588
|
-
* on name collision (their metadata is more precise). Non-colliding
|
|
589
|
-
* discovered fields are appended.
|
|
590
|
-
*/
|
|
591
|
-
mergeStaticAndCustomFields(staticFields, discovered) {
|
|
592
|
-
const existing = new Set(staticFields.map(f => f.Name.toLowerCase()));
|
|
593
|
-
const extra = discovered.filter(f => !existing.has(f.Name.toLowerCase()));
|
|
594
|
-
return [...staticFields, ...extra];
|
|
595
|
-
}
|
|
596
|
-
// ─── FetchChanges (OData $top/$skip pagination) ──────────────────
|
|
597
|
-
/**
|
|
598
|
-
* Fetches records for the given object using Wild Apricot's OData-style
|
|
599
|
-
* pagination. For Contacts, uses the `$async=true` pattern to dispatch
|
|
600
|
-
* long-running queries and polls the ResultUrl until ready.
|
|
601
|
-
*/
|
|
602
|
-
async FetchChanges(ctx) {
|
|
603
|
-
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
604
|
-
const objLower = ctx.ObjectName.toLowerCase();
|
|
605
|
-
const path = WA_API_PATHS[objLower];
|
|
606
|
-
if (!path) {
|
|
607
|
-
console.warn(`[WildApricot] No API path mapped for object "${ctx.ObjectName}"`);
|
|
608
|
-
return { Records: [], HasMore: false };
|
|
609
|
-
}
|
|
610
|
-
this.currentWatermark = ctx.WatermarkValue;
|
|
611
|
-
try {
|
|
612
|
-
if (objLower === 'contacts') {
|
|
613
|
-
return await this.fetchContactsAsync(auth, ctx);
|
|
614
|
-
}
|
|
615
|
-
return await this.fetchFlat(auth, ctx, path);
|
|
616
|
-
}
|
|
617
|
-
finally {
|
|
618
|
-
this.currentWatermark = null;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
/**
|
|
622
|
-
* Executes the standard OData flat fetch: GET {path}?$top={batchSize}&$skip={offset}
|
|
623
|
-
* with optional $filter for incremental sync.
|
|
624
|
-
*/
|
|
625
|
-
async fetchFlat(auth, ctx, path) {
|
|
626
|
-
const objLower = ctx.ObjectName.toLowerCase();
|
|
627
|
-
const dataKey = WA_RESPONSE_DATA_KEYS[objLower] ?? null;
|
|
628
|
-
const pageSize = ctx.BatchSize > 0 ? ctx.BatchSize : DEFAULT_PAGE_SIZE;
|
|
629
|
-
const offset = ctx.CurrentOffset ?? 0;
|
|
630
|
-
const url = this.buildListUrl(auth.BaseUrl, path, objLower, pageSize, offset, ctx.WatermarkValue);
|
|
631
|
-
const headers = this.BuildHeaders(auth);
|
|
632
|
-
const resp = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
633
|
-
if (resp.Status < 200 || resp.Status >= 300) {
|
|
634
|
-
throw new Error(`Wild Apricot GET ${path} returned HTTP ${resp.Status}`);
|
|
635
|
-
}
|
|
636
|
-
const records = this.extractRecords(resp.Body, dataKey);
|
|
637
|
-
const externalRecords = records.map(r => this.toExternalRecord(r, ctx.ObjectName));
|
|
638
|
-
const { hasMore, newWatermark } = this.computePageState(records, pageSize, objLower);
|
|
639
|
-
return {
|
|
640
|
-
Records: externalRecords,
|
|
641
|
-
HasMore: hasMore,
|
|
642
|
-
NextOffset: hasMore ? offset + records.length : undefined,
|
|
643
|
-
NewWatermarkValue: !hasMore && newWatermark ? newWatermark : undefined,
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Builds the URL for a flat list request, adding OData pagination and —
|
|
648
|
-
* when a watermark is present for a supported object — a server-side
|
|
649
|
-
* `$filter` with the Wild Apricot display-name-in-quotes syntax.
|
|
650
|
-
*/
|
|
651
|
-
buildListUrl(baseUrl, path, objLower, top, skip, watermark) {
|
|
652
|
-
const params = [`$top=${top}`, `$skip=${skip}`];
|
|
653
|
-
const wm = WA_WATERMARK_CONFIG[objLower];
|
|
654
|
-
if (wm && watermark) {
|
|
655
|
-
const filter = `'${wm.filterField}' ge '${watermark}'`;
|
|
656
|
-
params.push(`$filter=${encodeURIComponent(filter)}`);
|
|
657
|
-
}
|
|
658
|
-
return `${baseUrl}/${path}?${params.join('&')}`;
|
|
659
|
-
}
|
|
660
|
-
/**
|
|
661
|
-
* Extracts the record array from a Wild Apricot response. When `dataKey`
|
|
662
|
-
* is null the response body itself is expected to be the array.
|
|
663
|
-
*/
|
|
664
|
-
extractRecords(body, dataKey) {
|
|
665
|
-
if (Array.isArray(body))
|
|
666
|
-
return body;
|
|
667
|
-
if (!dataKey)
|
|
668
|
-
return [];
|
|
669
|
-
const obj = body;
|
|
670
|
-
if (!obj)
|
|
671
|
-
return [];
|
|
672
|
-
const arr = obj[dataKey];
|
|
673
|
-
return Array.isArray(arr) ? arr : [];
|
|
674
|
-
}
|
|
675
|
-
/**
|
|
676
|
-
* Computes HasMore and the rolling watermark for a page. Watermark is
|
|
677
|
-
* the max of the configured response field across returned records.
|
|
678
|
-
*/
|
|
679
|
-
computePageState(records, pageSize, objLower) {
|
|
680
|
-
const hasMore = records.length >= pageSize;
|
|
681
|
-
const wm = WA_WATERMARK_CONFIG[objLower];
|
|
682
|
-
if (!wm)
|
|
683
|
-
return { hasMore, newWatermark: null };
|
|
684
|
-
let latest = null;
|
|
685
|
-
for (const rec of records) {
|
|
686
|
-
const raw = rec[wm.responseField];
|
|
687
|
-
if (typeof raw !== 'string')
|
|
688
|
-
continue;
|
|
689
|
-
const parsed = new Date(raw);
|
|
690
|
-
if (isNaN(parsed.getTime()))
|
|
691
|
-
continue;
|
|
692
|
-
if (!latest || parsed > latest)
|
|
693
|
-
latest = parsed;
|
|
694
|
-
}
|
|
695
|
-
return { hasMore, newWatermark: latest ? latest.toISOString() : null };
|
|
696
|
-
}
|
|
697
|
-
/**
|
|
698
|
-
* Fetches contacts using Wild Apricot's async query pattern:
|
|
699
|
-
* GET /contacts?$async=true&$filter=... → returns ResultUrl
|
|
700
|
-
* poll ResultUrl until State=Complete
|
|
701
|
-
*/
|
|
702
|
-
async fetchContactsAsync(auth, ctx) {
|
|
703
|
-
const pageSize = ctx.BatchSize > 0 ? ctx.BatchSize : DEFAULT_PAGE_SIZE;
|
|
704
|
-
const offset = ctx.CurrentOffset ?? 0;
|
|
705
|
-
const params = ['$async=true', `$top=${pageSize}`, `$skip=${offset}`];
|
|
706
|
-
const wm = WA_WATERMARK_CONFIG['contacts'];
|
|
707
|
-
if (ctx.WatermarkValue && wm) {
|
|
708
|
-
const filter = `'${wm.filterField}' ge '${ctx.WatermarkValue}'`;
|
|
709
|
-
params.push(`$filter=${encodeURIComponent(filter)}`);
|
|
710
|
-
}
|
|
711
|
-
const url = `${auth.BaseUrl}/contacts?${params.join('&')}`;
|
|
712
|
-
const headers = this.BuildHeaders(auth);
|
|
713
|
-
const initial = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
714
|
-
if (initial.Status < 200 || initial.Status >= 300) {
|
|
715
|
-
throw new Error(`Wild Apricot async contacts initial call returned HTTP ${initial.Status}`);
|
|
716
|
-
}
|
|
717
|
-
const contacts = await this.resolveAsyncContactResult(auth, initial);
|
|
718
|
-
const externalRecords = contacts.map(r => this.toExternalRecord(r, ctx.ObjectName));
|
|
719
|
-
const { hasMore, newWatermark } = this.computePageState(contacts, pageSize, 'contacts');
|
|
720
|
-
return {
|
|
721
|
-
Records: externalRecords,
|
|
722
|
-
HasMore: hasMore,
|
|
723
|
-
NextOffset: hasMore ? offset + contacts.length : undefined,
|
|
724
|
-
NewWatermarkValue: !hasMore && newWatermark ? newWatermark : undefined,
|
|
725
|
-
};
|
|
726
|
-
}
|
|
727
|
-
/**
|
|
728
|
-
* If the initial async contacts response already contains the Contacts
|
|
729
|
-
* array (small queries return synchronously), returns it. Otherwise polls
|
|
730
|
-
* the ResultUrl at the configured interval until Complete or timeout.
|
|
731
|
-
*/
|
|
732
|
-
async resolveAsyncContactResult(auth, initial) {
|
|
733
|
-
const body = (initial.Body ?? {});
|
|
734
|
-
if (Array.isArray(body.Contacts)) {
|
|
735
|
-
return body.Contacts;
|
|
736
|
-
}
|
|
737
|
-
const resultUrl = body.ResultUrl;
|
|
738
|
-
if (typeof resultUrl !== 'string' || resultUrl.length === 0) {
|
|
739
|
-
console.warn('[WildApricot] Async contacts response has no ResultUrl and no inline Contacts — returning empty');
|
|
740
|
-
return [];
|
|
741
|
-
}
|
|
742
|
-
return this.pollAsyncContactsResultUrl(auth, resultUrl);
|
|
743
|
-
}
|
|
744
|
-
/** Polls the Wild Apricot async ResultUrl until it returns a Contacts array. */
|
|
745
|
-
async pollAsyncContactsResultUrl(auth, resultUrl) {
|
|
746
|
-
const interval = auth.Config.AsyncPollIntervalMs ?? DEFAULT_ASYNC_POLL_INTERVAL_MS;
|
|
747
|
-
const timeout = auth.Config.AsyncPollTimeoutMs ?? DEFAULT_ASYNC_POLL_TIMEOUT_MS;
|
|
748
|
-
const deadline = Date.now() + timeout;
|
|
749
|
-
const headers = this.BuildHeaders(auth);
|
|
750
|
-
while (Date.now() < deadline) {
|
|
751
|
-
await this.sleep(interval);
|
|
752
|
-
const resp = await this.MakeHTTPRequest(auth, resultUrl, 'GET', headers);
|
|
753
|
-
if (resp.Status === 202)
|
|
754
|
-
continue; // still processing
|
|
755
|
-
if (resp.Status < 200 || resp.Status >= 300) {
|
|
756
|
-
throw new Error(`Wild Apricot async ResultUrl poll returned HTTP ${resp.Status}`);
|
|
757
|
-
}
|
|
758
|
-
const body = (resp.Body ?? {});
|
|
759
|
-
if (Array.isArray(body.Contacts))
|
|
760
|
-
return body.Contacts;
|
|
761
|
-
}
|
|
762
|
-
throw new Error(`Wild Apricot async contacts query timed out after ${timeout}ms`);
|
|
763
|
-
}
|
|
764
|
-
toExternalRecord(raw, objectType) {
|
|
765
|
-
const idRaw = raw['Id'] ?? raw['id'] ?? raw['ID'];
|
|
766
|
-
const externalID = idRaw != null ? String(idRaw) : '';
|
|
767
|
-
return { ExternalID: externalID, ObjectType: objectType, Fields: raw };
|
|
768
|
-
}
|
|
769
|
-
supportsIncrementalSync(objectName) {
|
|
770
|
-
return WA_WATERMARK_CONFIG[objectName.toLowerCase()] != null;
|
|
771
|
-
}
|
|
772
|
-
// ─── CRUD ─────────────────────────────────────────────────────────
|
|
773
|
-
async CreateRecord(ctx) {
|
|
774
|
-
const companyIntegration = ctx.CompanyIntegration;
|
|
775
|
-
const contextUser = ctx.ContextUser;
|
|
776
|
-
return this.executeMutation(companyIntegration, contextUser, ctx.ObjectName, 'POST', null, ctx.Attributes, 'CreateRecord');
|
|
777
|
-
}
|
|
778
|
-
async UpdateRecord(ctx) {
|
|
779
|
-
const companyIntegration = ctx.CompanyIntegration;
|
|
780
|
-
const contextUser = ctx.ContextUser;
|
|
781
|
-
return this.executeMutation(companyIntegration, contextUser, ctx.ObjectName, 'PUT', ctx.ExternalID, ctx.Attributes, 'UpdateRecord');
|
|
782
|
-
}
|
|
783
|
-
async DeleteRecord(ctx) {
|
|
784
|
-
const companyIntegration = ctx.CompanyIntegration;
|
|
785
|
-
const contextUser = ctx.ContextUser;
|
|
786
|
-
return this.executeMutation(companyIntegration, contextUser, ctx.ObjectName, 'DELETE', ctx.ExternalID, undefined, 'DeleteRecord');
|
|
787
|
-
}
|
|
788
|
-
/**
|
|
789
|
-
* Shared CRUD implementation: routes POST/PUT/DELETE to the object's list
|
|
790
|
-
* path with an optional trailing `/{id}`. Returns a uniform CRUDResult.
|
|
791
|
-
*/
|
|
792
|
-
async executeMutation(companyIntegration, contextUser, objectName, method, externalID, attributes, operation) {
|
|
793
|
-
try {
|
|
794
|
-
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
795
|
-
const objLower = objectName.toLowerCase();
|
|
796
|
-
const path = WA_API_PATHS[objLower];
|
|
797
|
-
if (!path) {
|
|
798
|
-
return { Success: false, ErrorMessage: `WildApricot ${operation} not supported for "${objectName}"`, StatusCode: 400 };
|
|
799
|
-
}
|
|
800
|
-
const url = externalID != null
|
|
801
|
-
? `${auth.BaseUrl}/${path}/${encodeURIComponent(externalID)}`
|
|
802
|
-
: `${auth.BaseUrl}/${path}`;
|
|
803
|
-
const headers = this.BuildHeaders(auth);
|
|
804
|
-
const resp = await this.MakeHTTPRequest(auth, url, method, headers, attributes);
|
|
805
|
-
if (resp.Status < 200 || resp.Status >= 300) {
|
|
806
|
-
return this.buildCRUDError(resp, operation, objectName);
|
|
807
|
-
}
|
|
808
|
-
const body = (resp.Body ?? {});
|
|
809
|
-
const newIdRaw = body['Id'] ?? body['id'] ?? externalID;
|
|
810
|
-
const newId = newIdRaw != null ? String(newIdRaw) : undefined;
|
|
811
|
-
// CREATE-ONLY: a 2xx create with no usable record ID is a silent record-loss bug
|
|
812
|
-
// (duplicate creates next sync). Fail loudly via the base helper. Update/Delete keep
|
|
813
|
-
// their existing semantics — they legitimately may not echo an ID.
|
|
814
|
-
if (method === 'POST') {
|
|
815
|
-
return this.BuildCreatedResult(newId, resp.Status, objectName);
|
|
816
|
-
}
|
|
817
|
-
return {
|
|
818
|
-
Success: true,
|
|
819
|
-
ExternalID: newId,
|
|
820
|
-
StatusCode: resp.Status,
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
catch (err) {
|
|
824
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
825
|
-
return { Success: false, ErrorMessage: `WildApricot ${operation} failed: ${message}`, StatusCode: 500 };
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
buildCRUDError(resp, op, objectName) {
|
|
829
|
-
const body = resp.Body;
|
|
830
|
-
const rawMessage = typeof body?.['Message'] === 'string'
|
|
831
|
-
? body['Message']
|
|
832
|
-
: typeof body?.['message'] === 'string'
|
|
833
|
-
? body['message']
|
|
834
|
-
: undefined;
|
|
835
|
-
return {
|
|
836
|
-
Success: false,
|
|
837
|
-
ErrorMessage: rawMessage ?? `WildApricot ${op} on ${objectName} failed (HTTP ${resp.Status})`,
|
|
838
|
-
StatusCode: resp.Status,
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
// ─── Abstract REST hooks (BaseRESTIntegrationConnector) ───────────
|
|
842
|
-
async Authenticate(companyIntegration, contextUser) {
|
|
843
|
-
if (this.authState && this.isTokenValid(this.authState)) {
|
|
844
|
-
return this.authState;
|
|
845
|
-
}
|
|
846
|
-
const config = await this.parseConfig(companyIntegration, contextUser);
|
|
847
|
-
const token = await this.obtainAccessToken(config);
|
|
848
|
-
const accountId = config.AccountId ?? await this.resolveAccountId(token.access_token);
|
|
849
|
-
const apiVersion = config.ApiVersion ?? DEFAULT_API_VERSION;
|
|
850
|
-
const state = {
|
|
851
|
-
Token: token.access_token,
|
|
852
|
-
ExpiresAt: new Date(Date.now() + (token.expires_in * 1000)),
|
|
853
|
-
AccountId: accountId,
|
|
854
|
-
BaseUrl: `${WA_API_HOST}/${apiVersion}/accounts/${accountId}`,
|
|
855
|
-
Config: config,
|
|
856
|
-
};
|
|
857
|
-
this.authState = state;
|
|
858
|
-
return state;
|
|
859
|
-
}
|
|
860
|
-
BuildHeaders(auth) {
|
|
861
|
-
const waAuth = auth;
|
|
862
|
-
return {
|
|
863
|
-
'Authorization': `Bearer ${waAuth.Token}`,
|
|
864
|
-
'Accept': 'application/json',
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
/**
|
|
868
|
-
* Not used by FetchChanges (which implements its own pagination), but
|
|
869
|
-
* retained for compatibility with any callers that route through the
|
|
870
|
-
* base pipeline.
|
|
871
|
-
*/
|
|
872
|
-
NormalizeResponse(rawBody, responseDataKey) {
|
|
873
|
-
return this.extractRecords(rawBody, responseDataKey);
|
|
874
|
-
}
|
|
875
|
-
ExtractPaginationInfo(_rawBody, _paginationType, _currentPage, currentOffset, pageSize) {
|
|
876
|
-
// Wild Apricot uses skip-based pagination; we advance the base engine's offset by pageSize.
|
|
877
|
-
return { HasMore: false, NextOffset: currentOffset + pageSize };
|
|
878
|
-
}
|
|
879
|
-
GetBaseURL(_companyIntegration, auth) {
|
|
880
|
-
return auth.BaseUrl;
|
|
881
|
-
}
|
|
882
|
-
// ─── Token lifecycle ──────────────────────────────────────────────
|
|
883
|
-
isTokenValid(state) {
|
|
884
|
-
return state.ExpiresAt.getTime() - Date.now() > TOKEN_REFRESH_BUFFER_MS;
|
|
885
|
-
}
|
|
886
|
-
/**
|
|
887
|
-
* Exchanges the admin API Key for a bearer token via OAuth 2.0
|
|
888
|
-
* client_credentials grant. Per the Wild Apricot docs, `scope=auto` is
|
|
889
|
-
* required — without it the token has near-empty permissions.
|
|
890
|
-
*/
|
|
891
|
-
async obtainAccessToken(config) {
|
|
892
|
-
const basic = Buffer.from(`APIKEY:${config.ApiKey}`).toString('base64');
|
|
893
|
-
const resp = await fetch(WA_OAUTH_TOKEN_URL, {
|
|
894
|
-
method: 'POST',
|
|
895
|
-
headers: {
|
|
896
|
-
'Authorization': `Basic ${basic}`,
|
|
897
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
898
|
-
'Accept': 'application/json',
|
|
899
|
-
},
|
|
900
|
-
body: 'grant_type=client_credentials&scope=auto',
|
|
901
|
-
});
|
|
902
|
-
if (!resp.ok) {
|
|
903
|
-
const text = await resp.text();
|
|
904
|
-
throw new Error(`Wild Apricot OAuth token request failed (HTTP ${resp.status}): ${text.slice(0, 500)}`);
|
|
905
|
-
}
|
|
906
|
-
const payload = await resp.json();
|
|
907
|
-
if (!payload.access_token || typeof payload.access_token !== 'string') {
|
|
908
|
-
throw new Error('Wild Apricot OAuth token response missing access_token');
|
|
909
|
-
}
|
|
910
|
-
return payload;
|
|
911
|
-
}
|
|
912
|
-
/**
|
|
913
|
-
* Auto-discovers the account ID by calling GET /v2/accounts with the
|
|
914
|
-
* freshly obtained token. Selects the first account the API Key has
|
|
915
|
-
* access to. Most Wild Apricot keys are scoped to a single tenant.
|
|
916
|
-
*/
|
|
917
|
-
async resolveAccountId(accessToken) {
|
|
918
|
-
const url = `${WA_API_HOST}/v2/accounts`;
|
|
919
|
-
const resp = await fetch(url, {
|
|
920
|
-
method: 'GET',
|
|
921
|
-
headers: {
|
|
922
|
-
'Authorization': `Bearer ${accessToken}`,
|
|
923
|
-
'Accept': 'application/json',
|
|
924
|
-
},
|
|
925
|
-
});
|
|
926
|
-
if (!resp.ok) {
|
|
927
|
-
const text = await resp.text();
|
|
928
|
-
throw new Error(`Wild Apricot /v2/accounts failed (HTTP ${resp.status}): ${text.slice(0, 500)}`);
|
|
929
|
-
}
|
|
930
|
-
const body = await resp.json();
|
|
931
|
-
const accounts = Array.isArray(body) ? body : [body];
|
|
932
|
-
const first = accounts.find(a => typeof a?.Id === 'number');
|
|
933
|
-
if (!first) {
|
|
934
|
-
throw new Error('Wild Apricot /v2/accounts returned no accounts for this API Key');
|
|
935
|
-
}
|
|
936
|
-
return String(first.Id);
|
|
937
|
-
}
|
|
938
|
-
// ─── HTTP transport with retry + throttling ───────────────────────
|
|
939
|
-
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
940
|
-
const waAuth = auth;
|
|
941
|
-
const cfg = waAuth.Config;
|
|
942
|
-
const maxRetries = cfg.MaxRetries ?? DEFAULT_MAX_RETRIES;
|
|
943
|
-
const timeoutMs = cfg.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
944
|
-
const minInterval = cfg.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
|
|
945
|
-
let currentHeaders = headers;
|
|
946
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
947
|
-
await this.throttle(minInterval);
|
|
948
|
-
try {
|
|
949
|
-
const resp = await this.doFetch(url, method, currentHeaders, body, timeoutMs);
|
|
950
|
-
this.lastRequestTime = Date.now();
|
|
951
|
-
if (resp.Status === 401 && attempt < maxRetries) {
|
|
952
|
-
// Token expired or revoked — drop cache and refresh against the same
|
|
953
|
-
// credentials we already hold, then retry with the new bearer header.
|
|
954
|
-
this.authState = null;
|
|
955
|
-
const refreshedToken = await this.obtainAccessToken(cfg);
|
|
956
|
-
const accountId = cfg.AccountId ?? await this.resolveAccountId(refreshedToken.access_token);
|
|
957
|
-
const apiVersion = cfg.ApiVersion ?? DEFAULT_API_VERSION;
|
|
958
|
-
const refreshedState = {
|
|
959
|
-
Token: refreshedToken.access_token,
|
|
960
|
-
ExpiresAt: new Date(Date.now() + (refreshedToken.expires_in * 1000)),
|
|
961
|
-
AccountId: accountId,
|
|
962
|
-
BaseUrl: `${WA_API_HOST}/${apiVersion}/accounts/${accountId}`,
|
|
963
|
-
Config: cfg,
|
|
964
|
-
};
|
|
965
|
-
this.authState = refreshedState;
|
|
966
|
-
currentHeaders = this.BuildHeaders(refreshedState);
|
|
967
|
-
continue;
|
|
968
|
-
}
|
|
969
|
-
if (resp.Status === 429 || resp.Status === 503) {
|
|
970
|
-
if (attempt === maxRetries)
|
|
971
|
-
return resp;
|
|
972
|
-
await this.sleep(this.backoffFromResponse(resp, attempt));
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
975
|
-
return resp;
|
|
976
|
-
}
|
|
977
|
-
catch (err) {
|
|
978
|
-
if (attempt === maxRetries)
|
|
979
|
-
throw err;
|
|
980
|
-
if (!this.isRetryableError(err))
|
|
981
|
-
throw err;
|
|
982
|
-
await this.sleep(this.backoffMs(attempt));
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
throw new Error(`Wild Apricot request to ${url} exhausted ${maxRetries + 1} attempts`);
|
|
986
|
-
}
|
|
987
|
-
/** Single fetch() with AbortController-backed timeout. */
|
|
988
|
-
async doFetch(url, method, headers, body, timeoutMs) {
|
|
989
|
-
const controller = new AbortController();
|
|
990
|
-
const handle = setTimeout(() => controller.abort(), timeoutMs);
|
|
991
|
-
const finalHeaders = { ...headers };
|
|
992
|
-
if (body !== undefined && !finalHeaders['Content-Type']) {
|
|
993
|
-
finalHeaders['Content-Type'] = 'application/json';
|
|
994
|
-
}
|
|
995
|
-
try {
|
|
996
|
-
const resp = await fetch(url, {
|
|
997
|
-
method,
|
|
998
|
-
headers: finalHeaders,
|
|
999
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
1000
|
-
signal: controller.signal,
|
|
1001
|
-
});
|
|
1002
|
-
const respHeaders = {};
|
|
1003
|
-
resp.headers.forEach((value, key) => { respHeaders[key.toLowerCase()] = value; });
|
|
1004
|
-
const text = await resp.text();
|
|
1005
|
-
const parsed = text.length > 0 ? this.safeParseJSON(text) : null;
|
|
1006
|
-
return { Status: resp.status, Body: parsed, Headers: respHeaders };
|
|
1007
|
-
}
|
|
1008
|
-
finally {
|
|
1009
|
-
clearTimeout(handle);
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
safeParseJSON(text) {
|
|
1013
|
-
try {
|
|
1014
|
-
return JSON.parse(text);
|
|
1015
|
-
}
|
|
1016
|
-
catch {
|
|
1017
|
-
return text;
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
isRetryableError(err) {
|
|
1021
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1022
|
-
return /abort|timeout|ECONNRESET|ENOTFOUND|ETIMEDOUT|network/i.test(msg);
|
|
1023
|
-
}
|
|
1024
|
-
backoffMs(attempt) {
|
|
1025
|
-
const base = Math.min(1000 * Math.pow(2, attempt), 20000);
|
|
1026
|
-
const jitter = Math.floor(Math.random() * 500);
|
|
1027
|
-
return base + jitter;
|
|
1028
|
-
}
|
|
1029
|
-
/**
|
|
1030
|
-
* Derives a back-off duration from a 429/503 response, honoring a
|
|
1031
|
-
* Retry-After header when present (both delta-seconds and HTTP-date
|
|
1032
|
-
* forms are supported).
|
|
1033
|
-
*/
|
|
1034
|
-
backoffFromResponse(resp, attempt) {
|
|
1035
|
-
const retryAfter = resp.Headers['retry-after'];
|
|
1036
|
-
if (typeof retryAfter === 'string' && retryAfter.length > 0) {
|
|
1037
|
-
const asSeconds = Number(retryAfter);
|
|
1038
|
-
if (!isNaN(asSeconds) && asSeconds >= 0) {
|
|
1039
|
-
return Math.min(asSeconds * 1000, 30000);
|
|
1040
|
-
}
|
|
1041
|
-
const asDate = Date.parse(retryAfter);
|
|
1042
|
-
if (!isNaN(asDate)) {
|
|
1043
|
-
const delta = asDate - Date.now();
|
|
1044
|
-
if (delta > 0)
|
|
1045
|
-
return Math.min(delta, 30000);
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
return this.backoffMs(attempt);
|
|
1049
|
-
}
|
|
1050
|
-
async throttle(minIntervalMs) {
|
|
1051
|
-
const elapsed = Date.now() - this.lastRequestTime;
|
|
1052
|
-
if (elapsed < minIntervalMs) {
|
|
1053
|
-
await this.sleep(minIntervalMs - elapsed);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
sleep(ms) {
|
|
1057
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1058
|
-
}
|
|
1059
|
-
// ─── Config parsing ───────────────────────────────────────────────
|
|
1060
|
-
async parseConfig(companyIntegration, contextUser) {
|
|
1061
|
-
const credentialID = companyIntegration.CredentialID;
|
|
1062
|
-
if (credentialID) {
|
|
1063
|
-
const fromCred = await this.loadFromCredential(credentialID, contextUser);
|
|
1064
|
-
if (fromCred)
|
|
1065
|
-
return fromCred;
|
|
1066
|
-
}
|
|
1067
|
-
const raw = companyIntegration.Configuration;
|
|
1068
|
-
if (!raw) {
|
|
1069
|
-
throw new Error('WildApricotConnector: No credential or Configuration JSON found on CompanyIntegration');
|
|
1070
|
-
}
|
|
1071
|
-
const parsed = JSON.parse(raw);
|
|
1072
|
-
return this.validateConfig(parsed);
|
|
1073
|
-
}
|
|
1074
|
-
async loadFromCredential(credentialID, contextUser, provider) {
|
|
1075
|
-
const md = provider ?? new Metadata();
|
|
1076
|
-
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
1077
|
-
const loaded = await credential.Load(credentialID);
|
|
1078
|
-
if (!loaded || !credential.Values)
|
|
1079
|
-
return null;
|
|
1080
|
-
try {
|
|
1081
|
-
const raw = JSON.parse(credential.Values);
|
|
1082
|
-
// Accept both PascalCase (WildApricot-specific) and the standard
|
|
1083
|
-
// 'API Key' schema field name 'apiKey' (lowercase). AccountId is
|
|
1084
|
-
// optional — auto-discovered via GET /v2/accounts when omitted.
|
|
1085
|
-
const get = (...keys) => {
|
|
1086
|
-
for (const k of keys) {
|
|
1087
|
-
const hit = Object.entries(raw).find(([key]) => key.toLowerCase() === k.toLowerCase());
|
|
1088
|
-
if (hit && typeof hit[1] === 'string')
|
|
1089
|
-
return hit[1];
|
|
1090
|
-
}
|
|
1091
|
-
return undefined;
|
|
1092
|
-
};
|
|
1093
|
-
const apiKey = get('ApiKey', 'apiKey', 'api_key');
|
|
1094
|
-
const accountId = get('AccountId', 'accountId', 'account_id');
|
|
1095
|
-
if (!apiKey)
|
|
1096
|
-
return null;
|
|
1097
|
-
const parsed = {
|
|
1098
|
-
ApiKey: apiKey,
|
|
1099
|
-
AccountId: accountId,
|
|
1100
|
-
};
|
|
1101
|
-
return this.validateConfig(parsed);
|
|
1102
|
-
}
|
|
1103
|
-
catch {
|
|
1104
|
-
return null;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
validateConfig(raw) {
|
|
1108
|
-
if (!raw.ApiKey || typeof raw.ApiKey !== 'string') {
|
|
1109
|
-
throw new Error('WildApricotConnector: ApiKey is required');
|
|
1110
|
-
}
|
|
1111
|
-
return {
|
|
1112
|
-
ApiKey: raw.ApiKey,
|
|
1113
|
-
AccountId: raw.AccountId,
|
|
1114
|
-
ApiVersion: raw.ApiVersion ?? DEFAULT_API_VERSION,
|
|
1115
|
-
MaxRetries: raw.MaxRetries ?? DEFAULT_MAX_RETRIES,
|
|
1116
|
-
RequestTimeoutMs: raw.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
1117
|
-
MinRequestIntervalMs: raw.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS,
|
|
1118
|
-
AsyncPollIntervalMs: raw.AsyncPollIntervalMs ?? DEFAULT_ASYNC_POLL_INTERVAL_MS,
|
|
1119
|
-
AsyncPollTimeoutMs: raw.AsyncPollTimeoutMs ?? DEFAULT_ASYNC_POLL_TIMEOUT_MS,
|
|
1120
|
-
};
|
|
1121
|
-
}
|
|
1122
|
-
};
|
|
1123
|
-
WildApricotConnector = __decorate([
|
|
1124
|
-
RegisterClass(BaseIntegrationConnector, 'WildApricotConnector')
|
|
1125
|
-
], WildApricotConnector);
|
|
1126
|
-
export { WildApricotConnector };
|
|
1127
|
-
/** Tree-shaking prevention function — import and call from module entry point. */
|
|
1128
|
-
export function LoadWildApricotConnector() { }
|
|
1129
|
-
//# sourceMappingURL=WildApricotConnector.js.map
|