@memberjunction/integration-connectors 5.41.0 → 5.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/CventConnector.d.ts +146 -0
  2. package/dist/CventConnector.d.ts.map +1 -0
  3. package/dist/CventConnector.js +540 -0
  4. package/dist/CventConnector.js.map +1 -0
  5. package/dist/DynamicsDataverseConnector.d.ts +296 -0
  6. package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
  7. package/dist/DynamicsDataverseConnector.js +790 -0
  8. package/dist/DynamicsDataverseConnector.js.map +1 -0
  9. package/dist/FontevaConnector.d.ts +153 -0
  10. package/dist/FontevaConnector.d.ts.map +1 -0
  11. package/dist/FontevaConnector.js +421 -0
  12. package/dist/FontevaConnector.js.map +1 -0
  13. package/dist/GrowthZoneConnector.d.ts +123 -125
  14. package/dist/GrowthZoneConnector.d.ts.map +1 -1
  15. package/dist/GrowthZoneConnector.js +456 -499
  16. package/dist/GrowthZoneConnector.js.map +1 -1
  17. package/dist/HivebriteConnector.d.ts +151 -0
  18. package/dist/HivebriteConnector.d.ts.map +1 -0
  19. package/dist/HivebriteConnector.js +533 -0
  20. package/dist/HivebriteConnector.js.map +1 -0
  21. package/dist/IMISConnector.d.ts +112 -96
  22. package/dist/IMISConnector.d.ts.map +1 -1
  23. package/dist/IMISConnector.js +430 -417
  24. package/dist/IMISConnector.js.map +1 -1
  25. package/dist/MemberSuiteConnector.d.ts +185 -0
  26. package/dist/MemberSuiteConnector.d.ts.map +1 -0
  27. package/dist/MemberSuiteConnector.js +762 -0
  28. package/dist/MemberSuiteConnector.js.map +1 -0
  29. package/dist/NeonCRMConnector.d.ts +273 -0
  30. package/dist/NeonCRMConnector.d.ts.map +1 -0
  31. package/dist/NeonCRMConnector.js +1039 -0
  32. package/dist/NeonCRMConnector.js.map +1 -0
  33. package/dist/NetForumConnector.d.ts +140 -47
  34. package/dist/NetForumConnector.d.ts.map +1 -1
  35. package/dist/NetForumConnector.js +668 -395
  36. package/dist/NetForumConnector.js.map +1 -1
  37. package/dist/NetSuiteConnector.d.ts +116 -50
  38. package/dist/NetSuiteConnector.d.ts.map +1 -1
  39. package/dist/NetSuiteConnector.js +608 -510
  40. package/dist/NetSuiteConnector.js.map +1 -1
  41. package/dist/NimbleAMSConnector.d.ts +104 -11
  42. package/dist/NimbleAMSConnector.d.ts.map +1 -1
  43. package/dist/NimbleAMSConnector.js +552 -258
  44. package/dist/NimbleAMSConnector.js.map +1 -1
  45. package/dist/NoviConnector.d.ts +178 -0
  46. package/dist/NoviConnector.d.ts.map +1 -0
  47. package/dist/NoviConnector.js +566 -0
  48. package/dist/NoviConnector.js.map +1 -0
  49. package/dist/ORCIDConnector.d.ts +207 -0
  50. package/dist/ORCIDConnector.d.ts.map +1 -0
  51. package/dist/ORCIDConnector.js +708 -0
  52. package/dist/ORCIDConnector.js.map +1 -0
  53. package/dist/OpenWaterConnector.d.ts +173 -0
  54. package/dist/OpenWaterConnector.d.ts.map +1 -0
  55. package/dist/OpenWaterConnector.js +893 -0
  56. package/dist/OpenWaterConnector.js.map +1 -0
  57. package/dist/PathLMSConnector.d.ts +395 -0
  58. package/dist/PathLMSConnector.d.ts.map +1 -0
  59. package/dist/PathLMSConnector.js +1281 -0
  60. package/dist/PathLMSConnector.js.map +1 -0
  61. package/dist/PheedLoopConnector.d.ts +119 -0
  62. package/dist/PheedLoopConnector.d.ts.map +1 -0
  63. package/dist/PheedLoopConnector.js +429 -0
  64. package/dist/PheedLoopConnector.js.map +1 -0
  65. package/dist/PropFuelConnector.d.ts +135 -61
  66. package/dist/PropFuelConnector.d.ts.map +1 -1
  67. package/dist/PropFuelConnector.js +366 -467
  68. package/dist/PropFuelConnector.js.map +1 -1
  69. package/dist/RhythmConnector.d.ts +188 -0
  70. package/dist/RhythmConnector.d.ts.map +1 -0
  71. package/dist/RhythmConnector.js +784 -0
  72. package/dist/RhythmConnector.js.map +1 -0
  73. package/dist/SalesforceConnector.d.ts +69 -3
  74. package/dist/SalesforceConnector.d.ts.map +1 -1
  75. package/dist/SalesforceConnector.js +188 -166
  76. package/dist/SalesforceConnector.js.map +1 -1
  77. package/dist/SharePointConnector.d.ts +37 -30
  78. package/dist/SharePointConnector.d.ts.map +1 -1
  79. package/dist/SharePointConnector.js +163 -243
  80. package/dist/SharePointConnector.js.map +1 -1
  81. package/dist/YourMembershipConnector.d.ts.map +1 -1
  82. package/dist/YourMembershipConnector.js +13 -5
  83. package/dist/YourMembershipConnector.js.map +1 -1
  84. package/dist/index.d.ts +14 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +14 -3
  87. package/dist/index.js.map +1 -1
  88. package/package.json +8 -7
  89. package/dist/BettyConnector.d.ts +0 -288
  90. package/dist/BettyConnector.d.ts.map +0 -1
  91. package/dist/BettyConnector.js +0 -909
  92. package/dist/BettyConnector.js.map +0 -1
@@ -7,128 +7,52 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  /**
8
8
  * NimbleAMSConnector — Integration connector for Nimble AMS (Salesforce-native association management).
9
9
  *
10
- * API Documentation:
11
- * - Nimble AMS API: https://help.nimbleams.com/help/live/api
12
- * - Integration API: https://help.nimbleams.com/help/live/call-the-integration-api
13
- * - Object Reference: https://nimbleuser.github.io/nams-api-docs/
10
+ * Nimble AMS is a Salesforce-native AMS: data lives as Salesforce sObjects — standard SF objects
11
+ * (Account, Contact) and NU__/NUINT__-prefixed managed-package custom objects (`__c` suffix). The
12
+ * connector surfaces THREE access doors, each carried on the per-IO `Configuration` from the frozen
13
+ * contract — never hardcoded here:
14
+ *
15
+ * 1. Salesforce REST SOQL — `/services/data/v{apiVersion}/query` with `nextRecordsUrl` cursor
16
+ * pagination + `WHERE LastModifiedDate >= :watermark` incremental. The default read door, and
17
+ * the write door for sObject CRUD (`/sobjects/{ObjectName}` flat body, path-delete).
18
+ * 2. Nimble Fuse — POST `/services/apexrest/NUINT/NUIntegrationService`. OUTBOUND reads run a
19
+ * named Integration-Setting SOQL and return `{Records, RecordCount, Message}` (null fields are
20
+ * OMITTED — missing fields are nullable, NOT a schema error). INBOUND writes are an
21
+ * External-ID-keyed JSON upsert returning `{RecordCount, InboundResults:[{...,SalesforceId,
22
+ * ErrorMessages}], ErrorMessages}` with per-record partial success.
23
+ * 3. NAMS LMS REST — `/services/apexrest/nams/api/lms/v1/...` GET with a `lastUpdated` incremental.
14
24
  *
15
- * Auth: Salesforce OAuth 2.0 (Connected App — client_credentials or authorization_code flow)
16
- * Base URL: https://{instance}.salesforce.com/services/data/v59.0 (Salesforce REST API)
17
- * https://{instance}.salesforce.com/services/apexrest/NUINT/NUIntegrationService (Nimble Integration API)
18
- * Pagination: Salesforce standard — nextRecordsUrl in query results (2000 records/batch)
19
- * Rate limits: Salesforce org limits (100K API calls/24hr Enterprise, 15K Professional)
20
- * Incremental: SOQL WHERE LastModifiedDate >= watermark
21
- * CRUD: Full via Salesforce REST API /sobjects/{objectName}
25
+ * API Documentation:
26
+ * - Nimble AMS API: https://help.nimbleams.com/help/live/api
27
+ * - Call the Integration API: https://help.nimbleams.com/help/live/call-the-integration-api
28
+ * - Object Reference: https://nimbleuser.github.io/nams-api-docs/
22
29
  *
23
- * Nimble AMS Custom Objects (NU__ namespace):
24
- * All Nimble AMS objects use the NU__ namespace prefix on the Salesforce platform.
25
- * Standard Salesforce objects (Account, Contact) are also used alongside custom objects.
30
+ * Auth: Salesforce OAuth 2.0 bearer (Connected App; client_credentials or refresh_token grant).
31
+ * Base URL: the per-org Salesforce instance URL (from the OAuth token response `instance_url`).
26
32
  *
27
- * API Categories:
28
- * - Salesforce REST API (implemented) — SOQL queries, sObject CRUD on all NU__ objects
29
- * - Nimble Integration API (implemented) — simplified inbound/outbound via named Integration Settings
30
- * - Salesforce Bulk API 2.0 (NOT implemented) — for 10K+ record batches
31
- * - Salesforce Streaming API (NOT implemented) — real-time CDC via PushTopics/CDC channels
32
- * - Salesforce Metadata API (NOT implemented) — schema/config deployment, not data sync
33
+ * NO BAKED CATALOG. The object/field universe is Declared (credential-free docs → metadata file) +
34
+ * Discovered (runtime SF global-describe MECHANISM, scoped to the NU__/NUINT__ managed-package
35
+ * namespace). This file is pure mechanism: auth, HTTP, discover, fetch, normalize, transform, write.
33
36
  */
34
37
  import { RegisterClass } from '@memberjunction/global';
35
38
  import { Metadata } from '@memberjunction/core';
36
39
  import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
37
- // ─── Constants ────────────────────────────────────────────────────────────
38
- const SF_API_VERSION = 'v59.0';
39
- // Salesforce platform restricts SOQL `FIELDS(ALL)` to LIMIT <= 200 per query.
40
- // nextRecordsUrl cursor pagination chains additional batches.
41
- const SF_PAGE_SIZE = 200;
40
+ // ─── Constants ──────────────────────────────────────────────────────────────
41
+ const DEFAULT_SF_API_VERSION = '59.0';
42
+ const FUSE_PATH = '/services/apexrest/NUINT/NUIntegrationService';
43
+ /** SF managed-package namespaces that scope the global describe to Nimble objects. */
44
+ const NIMBLE_NAMESPACES = ['NU__', 'NUINT__'];
45
+ /** Standard SF objects used by Nimble AMS alongside the managed package. */
46
+ const NIMBLE_STANDARD_OBJECTS = new Set(['Account', 'Contact']);
47
+ /** Nimble Fuse OUTBOUND hard limits per call — 50,000 records OR 3MB, whichever is reached first. */
48
+ const FUSE_MAX_RECORDS_PER_CALL = 50_000;
49
+ const FUSE_MAX_BYTES_PER_CALL = 3 * 1024 * 1024; // 3MB
42
50
  const TOKEN_REFRESH_BUFFER_MS = 60_000;
51
+ const TOKEN_LIFETIME_MS = 3_600_000;
43
52
  const MAX_RETRIES = 3;
44
53
  const REQUEST_TIMEOUT_MS = 30_000;
45
54
  const MIN_REQUEST_INTERVAL_MS = 50;
46
- // ─── Nimble AMS Objects (NU__ namespace + standard SF objects) ─────────────
47
- // Lean overlay: PK + key FKs + date fields. Full field list from Salesforce describe API at runtime.
48
- const NIMBLE_OBJECTS = [
49
- // Standard Salesforce objects used by Nimble AMS
50
- { Name: 'Account', DisplayName: 'Account', Description: 'Organization/company accounts', SupportsWrite: true, Fields: [
51
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
52
- { Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
53
- ] },
54
- { Name: 'Contact', DisplayName: 'Contact', Description: 'Individual contacts/members', SupportsWrite: true, Fields: [
55
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
56
- { Name: 'AccountId', DisplayName: 'Account ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
57
- { Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
58
- ] },
59
- // Nimble AMS custom objects (NU__ namespace)
60
- { Name: 'NU__Membership__c', DisplayName: 'Membership', Description: 'Membership records', SupportsWrite: true, Fields: [
61
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
62
- { Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
63
- { Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
64
- ] },
65
- { Name: 'NU__MembershipType__c', DisplayName: 'Membership Type', Description: 'Membership type definitions', SupportsWrite: true, Fields: [
66
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
67
- ] },
68
- { Name: 'NU__Event__c', DisplayName: 'Event', Description: 'Events and conferences', SupportsWrite: true, Fields: [
69
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
70
- { Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
71
- ] },
72
- { Name: 'NU__EventRegistration__c', DisplayName: 'Event Registration', Description: 'Event registrations', SupportsWrite: true, Fields: [
73
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
74
- { Name: 'NU__Event__c', DisplayName: 'Event', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Event' },
75
- { Name: 'NU__Contact__c', DisplayName: 'Contact', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Contact' },
76
- ] },
77
- { Name: 'NU__Order__c', DisplayName: 'Order', Description: 'Sales orders', SupportsWrite: true, Fields: [
78
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
79
- { Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
80
- { Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
81
- ] },
82
- { Name: 'NU__OrderItem__c', DisplayName: 'Order Item', Description: 'Order line items', SupportsWrite: true, Fields: [
83
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
84
- { Name: 'NU__Order__c', DisplayName: 'Order', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Order' },
85
- ] },
86
- { Name: 'NU__Payment__c', DisplayName: 'Payment', Description: 'Payment records', SupportsWrite: true, Fields: [
87
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
88
- { Name: 'NU__Order__c', DisplayName: 'Order', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Order' },
89
- ] },
90
- { Name: 'NU__Invoice__c', DisplayName: 'Invoice', Description: 'Invoices', SupportsWrite: true, Fields: [
91
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
92
- { Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
93
- ] },
94
- { Name: 'NU__Committee__c', DisplayName: 'Committee', Description: 'Committees and boards', SupportsWrite: true, Fields: [
95
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
96
- ] },
97
- { Name: 'NU__CommitteeMembership__c', DisplayName: 'Committee Member', Description: 'Committee membership records', SupportsWrite: true, Fields: [
98
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
99
- { Name: 'NU__Committee__c', DisplayName: 'Committee', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Committee' },
100
- { Name: 'NU__Contact__c', DisplayName: 'Contact', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Contact' },
101
- ] },
102
- { Name: 'NU__Donation__c', DisplayName: 'Donation', Description: 'Donation/gift records', SupportsWrite: true, Fields: [
103
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
104
- { Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
105
- ] },
106
- { Name: 'NU__Product__c', DisplayName: 'Product', Description: 'Products/items for sale', SupportsWrite: true, Fields: [
107
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
108
- ] },
109
- { Name: 'NU__Badge__c', DisplayName: 'Badge', Description: 'Badges/certifications', SupportsWrite: true, Fields: [
110
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
111
- ] },
112
- { Name: 'NU__Subscription__c', DisplayName: 'Subscription', Description: 'Publication subscriptions', SupportsWrite: true, Fields: [
113
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
114
- { Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
115
- ] },
116
- { Name: 'NU__Campaign__c', DisplayName: 'Campaign', Description: 'Fundraising campaigns', SupportsWrite: true, Fields: [
117
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
118
- ] },
119
- { Name: 'NU__Session__c', DisplayName: 'Session', Description: 'Event sessions', SupportsWrite: true, Fields: [
120
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
121
- { Name: 'NU__Event__c', DisplayName: 'Event', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Event' },
122
- ] },
123
- { Name: 'NU__Credential__c', DisplayName: 'Credential', Description: 'Professional credentials/certifications', SupportsWrite: true, Fields: [
124
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
125
- { Name: 'NU__Contact__c', DisplayName: 'Contact', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Contact' },
126
- ] },
127
- { Name: 'NU__Chapter__c', DisplayName: 'Chapter', Description: 'Chapters/branches', SupportsWrite: true, Fields: [
128
- { Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
129
- ] },
130
- ];
131
- // ─── Connector ────────────────────────────────────────────────────────────
55
+ // ─── Connector ──────────────────────────────────────────────────────────────
132
56
  let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationConnector {
133
57
  constructor() {
134
58
  super(...arguments);
@@ -136,103 +60,188 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
136
60
  this.lastRequestTime = 0;
137
61
  }
138
62
  get IntegrationName() { return 'Nimble AMS'; }
63
+ // Capability getters reflect the contract: writeable objects expose SF-REST sObject CRUD
64
+ // (Create/Update/Delete) and Fuse upsert (Create/Update). The per-IO write columns gate which
65
+ // verb actually fires; these getters declare the connector CAN write where metadata says so.
139
66
  get SupportsCreate() { return true; }
140
67
  get SupportsUpdate() { return true; }
141
68
  get SupportsDelete() { return true; }
142
- GetIntegrationObjects() { return NIMBLE_OBJECTS; }
143
- GetActionGeneratorConfig() {
144
- const config = super.GetActionGeneratorConfig();
145
- if (!config)
146
- return null;
147
- config.IconClass = 'fa-solid fa-id-badge';
148
- config.CategoryDescription = 'Nimble AMS (Salesforce-native) for memberships, events, orders, and donations';
149
- config.ParentCategoryName = 'Association Management';
150
- config.IncludeSearch = true;
151
- config.IncludeList = true;
152
- return config;
153
- }
154
- // ─── Discovery ─────────────────────────────────────────────────────
69
+ /**
70
+ * AUTHORITATIVE discovery (§ deactivation gate): the live SF global describe returns the FULL set
71
+ * of NU__/NUINT__ + standard objects the credential can see, so absence on a later refresh genuinely
72
+ * means the source dropped it. The Declared baseline plus the describe extension are the complete
73
+ * gamut for the scoped namespace.
74
+ */
75
+ get DiscoveryIsAuthoritative() { return true; }
76
+ /**
77
+ * Route schema introspection through the connector's LIVE SF global-describe discovery.
78
+ *
79
+ * `BaseRESTIntegrationConnector` (our parent) overrides the grandparent's real discover loop with a
80
+ * CACHE-DRIVEN `IntrospectSchema` that re-reads persisted ACTIVE metadata and is hard-coded
81
+ * non-authoritative — so it never calls our `DiscoverObjects`/`DiscoverFields` and can never drive
82
+ * the authoritative deactivation overlay. Nimble HAS live describe-backed discovery (and declares
83
+ * `DiscoveryIsAuthoritative = true`), so we invoke the grandparent `BaseIntegrationConnector`
84
+ * implementation directly. It calls `this.DiscoverObjects` → `this.DiscoverFields` per object and
85
+ * sets `IsAuthoritative` from our getter, so the full field set is discovered and absent
86
+ * objects/fields deactivate (reversibly) on a comprehensive refresh.
87
+ */
88
+ async IntrospectSchema(companyIntegration, contextUser, options) {
89
+ return BaseIntegrationConnector.prototype.IntrospectSchema.call(this, companyIntegration, contextUser, options);
90
+ }
91
+ // ── §7 sync-efficiency hooks ─────────────────────────────────────────────
92
+ /** Salesforce REST is monotonic when we ORDER BY the watermark column — narrow the next incremental. */
93
+ get MonotonicWatermark() { return true; }
94
+ /** Every Nimble sObject carries the immutable 18-char `Id`; the LMS REST objects carry `id`. */
95
+ StableOrderingKey(objectName) {
96
+ return objectName.startsWith('Lms') ? 'id' : 'Id';
97
+ }
98
+ /**
99
+ * Salesforce enforces a per-org rolling 24h API-call cap (Enterprise ~100k/day) rather than a
100
+ * tokens/sec rate — a conservative sustained rate keeps the connector well clear of the daily cap.
101
+ */
102
+ get RateLimitPolicy() {
103
+ return { TokensPerSec: 20, Burst: 25, ThrottleBackoffFactor: 0.5 };
104
+ }
105
+ /** Salesforce 429 / REQUEST_LIMIT_EXCEEDED → honor the standard `Retry-After` header (seconds). */
106
+ ExtractRetryAfterMs(error) {
107
+ if (!error || typeof error !== 'object')
108
+ return undefined;
109
+ const headers = error.Headers;
110
+ const ra = headers?.['retry-after'];
111
+ if (ra) {
112
+ const secs = Number(ra);
113
+ if (!isNaN(secs))
114
+ return secs * 1000;
115
+ }
116
+ return undefined;
117
+ }
118
+ // ── Discovery (runtime SF global-describe MECHANISM, namespace-scoped) ────
155
119
  async DiscoverObjects(companyIntegration, contextUser) {
156
- // Try Salesforce describe API for dynamic discovery of NU__ objects
157
- try {
158
- const auth = await this.Authenticate(companyIntegration, contextUser);
159
- const headers = this.BuildHeaders(auth);
160
- const url = `${auth.InstanceURL}/services/data/${auth.ApiVersion}/sobjects`;
161
- const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
162
- if (response.Status === 200) {
163
- const body = response.Body;
164
- const results = [];
165
- for (const obj of (body.sobjects ?? [])) {
166
- // Include NU__ namespace objects + standard objects used by Nimble
167
- if (obj.name.startsWith('NU__') || ['Account', 'Contact', 'Lead', 'Opportunity', 'Campaign'].includes(obj.name)) {
168
- if (obj.queryable) {
169
- results.push({
170
- Name: obj.name, Label: obj.label, Description: `${obj.label} (Nimble AMS)`,
171
- SupportsIncrementalSync: true, SupportsWrite: obj.createable || obj.updateable,
172
- });
173
- }
174
- }
175
- }
176
- if (results.length > 0)
177
- return results;
178
- }
120
+ const auth = await this.Authenticate(companyIntegration, contextUser);
121
+ const url = `${this.SFDataBase(auth)}/sobjects/`;
122
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
123
+ if (response.Status < 200 || response.Status >= 300) {
124
+ throw new Error(`Nimble AMS DiscoverObjects failed: HTTP ${response.Status} at ${url}`);
179
125
  }
180
- catch { /* Fall through to static */ }
181
- return NIMBLE_OBJECTS.map(obj => ({
182
- Name: obj.Name, Label: obj.DisplayName, Description: obj.Description,
183
- SupportsIncrementalSync: true, SupportsWrite: obj.SupportsWrite ?? false,
184
- }));
126
+ const body = response.Body;
127
+ const results = [];
128
+ for (const obj of (body.sobjects ?? [])) {
129
+ if (!obj.queryable)
130
+ continue;
131
+ if (!this.IsNimbleScopedObject(obj.name))
132
+ continue;
133
+ results.push({
134
+ Name: obj.name,
135
+ Label: obj.label,
136
+ Description: `${obj.label} (Nimble AMS)`,
137
+ SupportsIncrementalSync: true,
138
+ SupportsWrite: obj.createable || obj.updateable,
139
+ });
140
+ }
141
+ return results;
185
142
  }
186
143
  async DiscoverFields(companyIntegration, objectName, contextUser) {
187
- // Salesforce describe endpoint for field metadata
188
- try {
189
- const auth = await this.Authenticate(companyIntegration, contextUser);
190
- const headers = this.BuildHeaders(auth);
191
- const url = `${auth.InstanceURL}/services/data/${auth.ApiVersion}/sobjects/${objectName}/describe`;
192
- const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
193
- if (response.Status === 200) {
194
- const body = response.Body;
195
- return (body.fields ?? []).map(f => ({
196
- Name: f.name, Label: f.label, Description: '', DataType: this.MapSFType(f.type),
197
- IsRequired: !f.nillable, IsUniqueKey: f.name === 'Id' || f.unique, IsReadOnly: !f.createable && !f.updateable,
198
- }));
199
- }
144
+ const auth = await this.Authenticate(companyIntegration, contextUser);
145
+ const url = `${this.SFDataBase(auth)}/sobjects/${encodeURIComponent(objectName)}/describe`;
146
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
147
+ if (response.Status < 200 || response.Status >= 300) {
148
+ throw new Error(`Nimble AMS DiscoverFields failed for "${objectName}": HTTP ${response.Status}`);
200
149
  }
201
- catch { /* Fall through to static */ }
202
- const obj = NIMBLE_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
203
- if (!obj)
204
- return [];
205
- return obj.Fields.map(f => ({ Name: f.Name, Label: f.DisplayName, Description: f.Description, DataType: f.Type, IsRequired: f.IsRequired, IsUniqueKey: f.IsPrimaryKey, IsReadOnly: f.IsReadOnly }));
150
+ const body = response.Body;
151
+ return (body.fields ?? []).map(f => {
152
+ const schema = {
153
+ Name: f.name,
154
+ Label: f.label,
155
+ Description: '',
156
+ DataType: this.MapSFType(f.type),
157
+ // SF describe states create-time required via `nillable=false` (provable from the source).
158
+ IsRequired: f.nillable === false,
159
+ // Provable-only: the SF `id` system field is the PK; uniqueness alone is NOT PK.
160
+ IsPrimaryKey: f.type === 'id',
161
+ IsUniqueKey: f.type === 'id' || f.unique === true,
162
+ IsReadOnly: !f.createable && !f.updateable,
163
+ };
164
+ // Bounded typing — propagate the source's length/precision/scale when present.
165
+ if (typeof f.length === 'number' && f.length > 0)
166
+ schema.MaxLength = f.length;
167
+ if (typeof f.precision === 'number' && f.precision > 0)
168
+ schema.Precision = f.precision;
169
+ if (typeof f.scale === 'number' && f.scale >= 0)
170
+ schema.Scale = f.scale;
171
+ return schema;
172
+ });
173
+ }
174
+ /** A SF API name is in Nimble scope iff it is a managed-package object or a standard AMS object. */
175
+ IsNimbleScopedObject(name) {
176
+ if (NIMBLE_STANDARD_OBJECTS.has(name))
177
+ return true;
178
+ return NIMBLE_NAMESPACES.some(ns => name.startsWith(ns));
206
179
  }
207
180
  MapSFType(sfType) {
208
181
  const map = {
209
- 'id': 'string', 'string': 'string', 'textarea': 'string', 'url': 'string', 'email': 'string', 'phone': 'string',
210
- 'boolean': 'boolean', 'int': 'number', 'double': 'decimal', 'currency': 'decimal', 'percent': 'decimal',
211
- 'date': 'datetime', 'datetime': 'datetime', 'reference': 'string', 'picklist': 'string', 'multipicklist': 'string',
182
+ id: 'string', string: 'string', textarea: 'string', url: 'string', email: 'string', phone: 'string',
183
+ boolean: 'boolean', int: 'number', double: 'decimal', currency: 'decimal', percent: 'decimal',
184
+ date: 'datetime', datetime: 'datetime', time: 'string', reference: 'string',
185
+ picklist: 'string', multipicklist: 'string', base64: 'string', anyType: 'string', address: 'string',
212
186
  };
213
187
  return map[sfType] ?? 'string';
214
188
  }
215
- // ─── Auth ──────────────────────────────────────────────────────────
189
+ // ── Auth (Salesforce OAuth2 bearer) ──────────────────────────────────────
216
190
  async Authenticate(companyIntegration, contextUser) {
217
191
  const config = await this.ParseConfig(companyIntegration, contextUser);
218
192
  if (this.tokenCache && this.tokenCache.ExpiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS) {
219
- return { Token: this.tokenCache.AccessToken, TokenType: 'Bearer', Config: config, InstanceURL: config.InstanceURL, ApiVersion: SF_API_VERSION };
193
+ return this.BuildAuthContext(config, this.tokenCache.AccessToken, this.tokenCache.InstanceURL);
220
194
  }
221
195
  const token = await this.ObtainToken(config);
222
196
  this.tokenCache = token;
223
- return { Token: token.AccessToken, TokenType: 'Bearer', Config: config, InstanceURL: config.InstanceURL, ApiVersion: SF_API_VERSION };
197
+ return this.BuildAuthContext(config, token.AccessToken, token.InstanceURL);
198
+ }
199
+ BuildAuthContext(config, accessToken, instanceURL) {
200
+ return {
201
+ Token: accessToken,
202
+ TokenType: 'Bearer',
203
+ Config: config,
204
+ InstanceURL: instanceURL,
205
+ ApiVersion: config.ApiVersion,
206
+ };
224
207
  }
225
208
  async ObtainToken(config) {
226
- const tokenURL = `${config.InstanceURL}/services/oauth2/token`;
227
- const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: config.ClientID, client_secret: config.ClientSecret });
228
- const response = await fetch(tokenURL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
209
+ // Pre-provisioned access token (e.g. test/broker injection) — use as-is, no exchange.
210
+ if (config.AccessToken && config.InstanceURL) {
211
+ return { AccessToken: config.AccessToken, InstanceURL: config.InstanceURL, ExpiresAt: Date.now() + TOKEN_LIFETIME_MS };
212
+ }
213
+ const tokenURL = `${config.LoginURL.replace(/\/+$/, '')}/services/oauth2/token`;
214
+ const params = { client_id: config.ClientID };
215
+ if (config.RefreshToken) {
216
+ params['grant_type'] = 'refresh_token';
217
+ params['refresh_token'] = config.RefreshToken;
218
+ if (config.ClientSecret)
219
+ params['client_secret'] = config.ClientSecret;
220
+ }
221
+ else {
222
+ if (!config.ClientSecret)
223
+ throw new Error('Nimble AMS auth requires either a refresh_token or a client_secret.');
224
+ params['grant_type'] = 'client_credentials';
225
+ params['client_secret'] = config.ClientSecret;
226
+ }
227
+ const body = new URLSearchParams(params);
228
+ const response = await fetch(tokenURL, {
229
+ method: 'POST',
230
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
231
+ body: body.toString(),
232
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
233
+ });
229
234
  if (!response.ok)
230
- throw new Error(`Nimble AMS auth failed: ${response.status}`);
235
+ throw new Error(`Nimble AMS auth failed: HTTP ${response.status}`);
231
236
  const data = await response.json();
232
- return { AccessToken: data.access_token, ExpiresAt: Date.now() + (3600 * 1000) };
237
+ // Salesforce returns the org instance_url in the token response — it is the authoritative origin.
238
+ const instanceURL = data.instance_url ?? config.InstanceURL;
239
+ if (!instanceURL)
240
+ throw new Error('Nimble AMS auth response carried no instance_url and none was configured.');
241
+ return { AccessToken: data.access_token, InstanceURL: instanceURL, ExpiresAt: Date.now() + TOKEN_LIFETIME_MS };
233
242
  }
234
243
  async ParseConfig(companyIntegration, contextUser, provider) {
235
- // Use the typed property — never .Get()/.Set() on entity-typed objects (CLAUDE.md §2b).
244
+ const fromConfigJSON = this.ParseConfigJSON(companyIntegration.Configuration);
236
245
  const credentialID = companyIntegration.CredentialID;
237
246
  if (credentialID) {
238
247
  const md = provider ?? new Metadata();
@@ -240,112 +249,382 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
240
249
  const loaded = await credential.Load(credentialID);
241
250
  if (loaded && credential.Values) {
242
251
  const parsed = JSON.parse(credential.Values);
243
- return { InstanceURL: parsed['InstanceURL'] ?? '', ClientID: parsed['ClientID'] ?? parsed['clientId'] ?? '', ClientSecret: parsed['ClientSecret'] ?? parsed['clientSecret'] ?? '', AccessToken: parsed['AccessToken'], RefreshToken: parsed['RefreshToken'] };
252
+ return this.BuildConfig(parsed, fromConfigJSON);
244
253
  }
245
254
  }
246
- throw new Error('No Nimble AMS credentials found.');
255
+ if (fromConfigJSON)
256
+ return this.BuildConfig(fromConfigJSON, null);
257
+ throw new Error('No Nimble AMS credentials found — set the Credential entity or Configuration JSON.');
258
+ }
259
+ ParseConfigJSON(json) {
260
+ if (!json)
261
+ return null;
262
+ try {
263
+ return JSON.parse(json);
264
+ }
265
+ catch {
266
+ return null;
267
+ }
268
+ }
269
+ BuildConfig(creds, configJSON) {
270
+ const pick = (...keys) => {
271
+ for (const k of keys) {
272
+ if (creds[k])
273
+ return creds[k];
274
+ if (configJSON && configJSON[k])
275
+ return configJSON[k];
276
+ }
277
+ return undefined;
278
+ };
279
+ return {
280
+ InstanceURL: pick('InstanceURL', 'instanceUrl', 'instance_url') ?? '',
281
+ ClientID: pick('ClientID', 'clientId', 'client_id') ?? '',
282
+ ClientSecret: pick('ClientSecret', 'clientSecret', 'client_secret'),
283
+ AccessToken: pick('AccessToken', 'accessToken', 'access_token'),
284
+ RefreshToken: pick('RefreshToken', 'refreshToken', 'refresh_token'),
285
+ LoginURL: pick('LoginURL', 'loginUrl', 'login_url') ?? 'https://login.salesforce.com',
286
+ ApiVersion: this.NormalizeApiVersion(pick('SalesforceAPIVersion', 'ApiVersion', 'apiVersion')),
287
+ };
288
+ }
289
+ /** Accept "59.0" or "v59.0"; the SF data path embeds it as `v{version}`. Store the bare number. */
290
+ NormalizeApiVersion(raw) {
291
+ if (!raw)
292
+ return DEFAULT_SF_API_VERSION;
293
+ return raw.replace(/^v/i, '');
247
294
  }
248
- // ─── TestConnection ────────────────────────────────────────────────
295
+ // ── TestConnection ───────────────────────────────────────────────────────
249
296
  async TestConnection(companyIntegration, contextUser) {
250
297
  try {
251
298
  const auth = await this.Authenticate(companyIntegration, contextUser);
252
- const headers = this.BuildHeaders(auth);
253
- const response = await this.MakeHTTPRequest(auth, `${auth.InstanceURL}/services/data/${auth.ApiVersion}/sobjects`, 'GET', headers);
254
- if (response.Status === 200)
299
+ const response = await this.MakeHTTPRequest(auth, `${this.SFDataBase(auth)}/sobjects/`, 'GET', this.BuildHeaders(auth));
300
+ if (response.Status >= 200 && response.Status < 300)
255
301
  return { Success: true, Message: 'Connected to Nimble AMS (Salesforce)' };
256
- return { Success: false, Message: `API returned ${response.Status}` };
302
+ return { Success: false, Message: `Nimble AMS API returned HTTP ${response.Status}` };
257
303
  }
258
304
  catch (err) {
259
305
  return { Success: false, Message: err instanceof Error ? err.message : String(err) };
260
306
  }
261
307
  }
262
- // ─── URL / Response / Pagination ───────────────────────────────────
308
+ // ── URL / Response / Pagination ──────────────────────────────────────────
263
309
  GetBaseURL(_ci, auth) {
264
- const a = auth;
265
- return `${a.InstanceURL}/services/data/${a.ApiVersion}`;
310
+ return auth.InstanceURL;
266
311
  }
267
- BuildPaginatedURL(basePath, _obj, _page, _offset, cursor) {
268
- return cursor ?? basePath;
312
+ /** The instance origin for all REST calls. Routes through GetBaseURL so a test harness can redirect. */
313
+ Origin(auth) {
314
+ return this.GetBaseURL(undefined, auth);
269
315
  }
316
+ /** The `/services/data/v{version}` base for all Salesforce REST calls. */
317
+ SFDataBase(auth) {
318
+ return `${this.Origin(auth)}/services/data/v${auth.ApiVersion}`;
319
+ }
320
+ /** Strip the SF `attributes` envelope blob from every record (sanctioned, declared removal). */
321
+ TransformRecord(raw, _obj, _fields) {
322
+ if (!('attributes' in raw))
323
+ return raw;
324
+ const out = { ...raw };
325
+ delete out['attributes'];
326
+ return out;
327
+ }
328
+ ExcludedSourceKeys(_objectName) {
329
+ return ['attributes'];
330
+ }
331
+ /**
332
+ * Strip the response envelope to expose individual records. Handles all three doors:
333
+ * - SF SOQL → `body.records`
334
+ * - Fuse out → `body.Records` (note: PascalCase; null fields omitted per record)
335
+ * - LMS REST → root array or single object
336
+ */
270
337
  NormalizeResponse(rawBody, _key) {
271
338
  if (Array.isArray(rawBody))
272
339
  return rawBody;
340
+ if (!rawBody || typeof rawBody !== 'object')
341
+ return [];
273
342
  const body = rawBody;
274
343
  if (Array.isArray(body['records']))
275
344
  return body['records'];
276
- if (body && Object.keys(body).length > 0)
277
- return [body];
278
- return [];
345
+ if (Array.isArray(body['Records']))
346
+ return body['Records'];
347
+ // A single non-enveloped record (e.g. LMS get-one) — pass through as one record.
348
+ return [body];
279
349
  }
280
350
  ExtractPaginationInfo(rawBody, _pt, _cp, _co, _ps) {
281
351
  const body = rawBody;
282
- return { HasMore: !body.done, NextCursor: body.nextRecordsUrl };
352
+ return { HasMore: body?.done === false, NextCursor: body?.nextRecordsUrl };
283
353
  }
284
- // ─── FetchChanges — SOQL-based ─────────────────────────────────────
354
+ // ── FetchChanges (door-aware) ────────────────────────────────────────────
285
355
  async FetchChanges(ctx) {
356
+ const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
357
+ const cfg = this.ReadAccessConfig(obj);
358
+ const family = cfg.Family ?? '';
359
+ if (family === 'FuseEndpoint') {
360
+ // The Fuse endpoint IO is a contract descriptor, not a queryable record type — nothing to pull.
361
+ return { Records: [], HasMore: false, Warnings: [{ Code: 'NON_QUERYABLE', Message: `"${ctx.ObjectName}" is the Fuse contract descriptor; it carries no syncable records.` }] };
362
+ }
363
+ if (family === 'LmsFamily') {
364
+ return this.FetchLMS(ctx, obj);
365
+ }
366
+ // Default door: Salesforce REST SOQL (also used by the Fuse outbound named-SOQL setting when one
367
+ // is configured — the SOQL path is the credential-free-verifiable common case).
368
+ return this.FetchSOQL(ctx, obj);
369
+ }
370
+ /** Salesforce REST SOQL fetch — cursor pagination via nextRecordsUrl + watermark-ordered incremental. */
371
+ async FetchSOQL(ctx, obj) {
286
372
  const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
287
373
  const headers = this.BuildHeaders(auth);
288
- let soql = `SELECT FIELDS(ALL) FROM ${ctx.ObjectName} ORDER BY LastModifiedDate ASC LIMIT ${SF_PAGE_SIZE}`;
289
- if (ctx.WatermarkValue) {
290
- soql = `SELECT FIELDS(ALL) FROM ${ctx.ObjectName} WHERE LastModifiedDate >= ${ctx.WatermarkValue} ORDER BY LastModifiedDate ASC LIMIT ${SF_PAGE_SIZE}`;
291
- }
374
+ const watermarkField = obj.IncrementalWatermarkField ?? 'LastModifiedDate';
375
+ // Follow the SF cursor when present; otherwise build the first SOQL page.
292
376
  const url = ctx.CurrentCursor
293
- ? `${auth.InstanceURL}${ctx.CurrentCursor}`
294
- : `${auth.InstanceURL}/services/data/${auth.ApiVersion}/query?q=${encodeURIComponent(soql)}`;
377
+ ? `${this.GetBaseURL(ctx.CompanyIntegration, auth)}${ctx.CurrentCursor}`
378
+ : `${this.SFDataBase(auth)}/query?q=${encodeURIComponent(this.BuildSOQL(ctx.ObjectName, watermarkField, ctx.WatermarkValue ?? null))}`;
295
379
  const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
296
- if (response.Status < 200 || response.Status >= 300)
297
- throw new Error(`Nimble AMS SOQL error: ${response.Status}`);
380
+ if (response.Status < 200 || response.Status >= 300) {
381
+ throw new Error(`Nimble AMS SOQL error for "${ctx.ObjectName}": HTTP ${response.Status} — ${this.SafeBody(response)}`);
382
+ }
298
383
  const body = response.Body;
299
- const records = body.records ?? [];
300
- const externalRecords = records.map(r => ({ ExternalID: String(r['Id'] ?? ''), ObjectType: ctx.ObjectName, Fields: r }));
384
+ const raw = body.records ?? [];
385
+ // Defensive: the Fuse OUTBOUND door caps a single call at 50,000 records; SF native paging
386
+ // (≤2000/page) keeps SOQL pages well under it, so a page at/over the cap signals a misconfig.
387
+ const warnings = raw.length >= FUSE_MAX_RECORDS_PER_CALL
388
+ ? [{ Code: 'PAGE_AT_RECORD_CAP', Message: `"${ctx.ObjectName}" returned ${raw.length} records in one page — at/over the ${FUSE_MAX_RECORDS_PER_CALL}-record Fuse per-call cap.` }]
389
+ : undefined;
390
+ const records = raw.map(r => this.RawToExternalRecord(r, obj, ctx.ObjectName, watermarkField, 'Id'));
391
+ // Watermark advances ONLY on the final page (full-batch success), and only to the max seen.
301
392
  let newWatermark;
302
393
  if (body.done) {
303
- for (const r of records) {
304
- const mod = r['LastModifiedDate'];
305
- if (mod && (!newWatermark || mod > newWatermark))
394
+ for (const r of raw) {
395
+ const mod = r[watermarkField];
396
+ if (typeof mod === 'string' && (!newWatermark || mod > newWatermark))
306
397
  newWatermark = mod;
307
398
  }
308
399
  }
309
- return { Records: externalRecords, HasMore: !body.done, NextCursor: body.nextRecordsUrl, NewWatermarkValue: body.done ? newWatermark : undefined };
400
+ return {
401
+ Records: records,
402
+ HasMore: body.done === false,
403
+ NextCursor: body.nextRecordsUrl,
404
+ NewWatermarkValue: body.done ? newWatermark : undefined,
405
+ Warnings: warnings,
406
+ };
310
407
  }
311
- // ─── CRUD ──────────────────────────────────────────────────────────
312
- async CreateRecord(ctx) {
408
+ /**
409
+ * Build the SOQL for the first page. CRITICAL: NO `LIMIT` — Salesforce natively paginates via
410
+ * done/nextRecordsUrl, so a SOQL LIMIT would cap the ENTIRE result set and SF would report
411
+ * done=true at that count, silently dropping every record past the limit AND advancing the
412
+ * watermark past them next sync. Use `>=` (not `>`) so records at the exact watermark instant
413
+ * aren't lost; engine dedupe absorbs the boundary re-fetch. ORDER BY the watermark for monotonicity.
414
+ */
415
+ BuildSOQL(objectName, watermarkField, watermarkValue) {
416
+ let soql = `SELECT FIELDS(ALL) FROM ${objectName}`;
417
+ if (watermarkValue)
418
+ soql += ` WHERE ${watermarkField} >= ${this.FormatSOQLDateTime(watermarkValue)}`;
419
+ soql += ` ORDER BY ${watermarkField} ASC`;
420
+ return soql;
421
+ }
422
+ /** SF SOQL datetime literals are unquoted ISO-8601 (e.g. 2026-01-01T00:00:00Z). */
423
+ FormatSOQLDateTime(value) {
424
+ // Already an unquoted ISO literal → use verbatim; otherwise emit a UTC ISO string.
425
+ if (/^\d{4}-\d{2}-\d{2}T/.test(value))
426
+ return value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value) ? value : `${value}Z`;
427
+ const d = new Date(value);
428
+ return isNaN(d.getTime()) ? value : d.toISOString();
429
+ }
430
+ /** NAMS LMS REST fetch — GET with an optional `lastUpdated` incremental filter (no pagination). */
431
+ async FetchLMS(ctx, obj) {
313
432
  const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
433
+ const headers = this.BuildHeaders(auth);
434
+ const watermarkField = obj.IncrementalWatermarkField ?? 'lastUpdated';
435
+ const base = `${this.GetBaseURL(ctx.CompanyIntegration, auth)}${obj.APIPath}`;
436
+ const url = ctx.WatermarkValue
437
+ ? `${base}?${encodeURIComponent(watermarkField)}=${encodeURIComponent(ctx.WatermarkValue)}`
438
+ : base;
439
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
440
+ if (response.Status < 200 || response.Status >= 300) {
441
+ throw new Error(`Nimble AMS LMS error for "${ctx.ObjectName}": HTTP ${response.Status} — ${this.SafeBody(response)}`);
442
+ }
443
+ const raw = this.NormalizeResponse(response.Body, null);
444
+ const records = raw.map(r => this.RawToExternalRecord(r, obj, ctx.ObjectName, watermarkField, 'id'));
445
+ let newWatermark;
446
+ for (const r of raw) {
447
+ const mod = r[watermarkField];
448
+ if (typeof mod === 'string' && (!newWatermark || mod > newWatermark))
449
+ newWatermark = mod;
450
+ }
451
+ // LMS REST returns the full set in one response (no cursor) → batch is always complete.
452
+ return { Records: records, HasMore: false, NewWatermarkValue: newWatermark };
453
+ }
454
+ // ── CRUD (door-aware: Fuse inbound upsert vs Salesforce REST sObject) ─────
455
+ async CreateRecord(ctx) {
456
+ const ci = ctx.CompanyIntegration;
457
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
458
+ if (this.IsFuseWritePath(obj)) {
459
+ return this.FuseUpsert(ctx.ContextUser, ci, obj, ctx.ObjectName, ctx.Attributes, /*forUpdate*/ false);
460
+ }
461
+ return this.SFRestCreate(ctx.ContextUser, ci, ctx.ObjectName, ctx.Attributes, obj);
462
+ }
463
+ async UpdateRecord(ctx) {
464
+ const ci = ctx.CompanyIntegration;
465
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
466
+ if (this.IsFuseWritePath(obj)) {
467
+ // Fuse is upsert-keyed by External ID — the External ID rides in the record body, not the path.
468
+ const result = await this.FuseUpsert(ctx.ContextUser, ci, obj, ctx.ObjectName, ctx.Attributes, /*forUpdate*/ true);
469
+ // For update we report the supplied ExternalID on success (Fuse returns the SalesforceId).
470
+ return result.Success ? { Success: true, StatusCode: result.StatusCode, ExternalID: result.ExternalID ?? ctx.ExternalID } : result;
471
+ }
472
+ return this.SFRestUpdate(ctx.ContextUser, ci, ctx.ObjectName, ctx.ExternalID, ctx.Attributes, obj);
473
+ }
474
+ async DeleteRecord(ctx) {
475
+ const ci = ctx.CompanyIntegration;
476
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
477
+ if (!obj.DeleteAPIPath || !obj.DeleteMethod) {
478
+ return { Success: false, StatusCode: 0, ErrorMessage: `DeleteRecord not supported for "${ctx.ObjectName}": Fuse is upsert-only; hard delete requires Salesforce REST sObject DELETE, which this object does not declare.` };
479
+ }
480
+ const auth = await this.Authenticate(ci, ctx.ContextUser);
481
+ const url = this.ResolveSFRestPath(auth, obj.DeleteAPIPath, ctx.ObjectName, ctx.ExternalID);
482
+ const response = await this.MakeHTTPRequest(auth, url, obj.DeleteMethod, this.BuildHeaders(auth));
483
+ if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
484
+ return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
485
+ }
486
+ return { Success: false, StatusCode: response.Status, ExternalID: '', ErrorMessage: `DeleteRecord failed for "${ctx.ObjectName}": HTTP ${response.Status} — ${this.SafeBody(response)}` };
487
+ }
488
+ /** Salesforce REST sObject create — POST `/sobjects/{ObjectName}` flat body, ID in response `id`. */
489
+ async SFRestCreate(contextUser, ci, objectName, attributes, obj) {
490
+ const auth = await this.Authenticate(ci, contextUser);
491
+ const createPath = obj.CreateAPIPath ?? `/services/data/v{apiVersion}/sobjects/{ObjectName}/`;
492
+ const url = this.ResolveSFRestPath(auth, createPath, objectName, undefined);
314
493
  const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
315
- const url = `${auth.InstanceURL}/services/data/${auth.ApiVersion}/sobjects/${ctx.ObjectName}`;
316
- const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, ctx.Attributes);
494
+ const response = await this.MakeHTTPRequest(auth, url, obj.CreateMethod ?? 'POST', headers, attributes);
317
495
  if (response.Status >= 200 && response.Status < 300) {
318
496
  const body = response.Body;
319
- const newID = body['id'] == null ? undefined : String(body['id']);
320
- return this.BuildCreatedResult(newID, response.Status, ctx.ObjectName);
497
+ const newID = body?.['id'] == null ? undefined : String(body['id']);
498
+ return this.BuildCreatedResult(newID, response.Status, objectName); // fail LOUDLY on empty id
321
499
  }
322
- return this.BuildCRUDError(response, 'CreateRecord', ctx.ObjectName);
500
+ return { Success: false, StatusCode: response.Status, ErrorMessage: `CreateRecord failed for "${objectName}": HTTP ${response.Status} — ${this.SafeBody(response)}` };
323
501
  }
324
- async UpdateRecord(ctx) {
325
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
502
+ /** Salesforce REST sObject update — PATCH `/sobjects/{ObjectName}/{Id}` flat body. */
503
+ async SFRestUpdate(contextUser, ci, objectName, externalID, attributes, obj) {
504
+ const auth = await this.Authenticate(ci, contextUser);
505
+ const updatePath = obj.UpdateAPIPath ?? `/services/data/v{apiVersion}/sobjects/{ObjectName}/{Id}`;
506
+ const url = this.ResolveSFRestPath(auth, updatePath, objectName, externalID);
326
507
  const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
327
- const url = `${auth.InstanceURL}/services/data/${auth.ApiVersion}/sobjects/${ctx.ObjectName}/${ctx.ExternalID}`;
328
- const response = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, ctx.Attributes);
329
- if (response.Status >= 200 && response.Status < 300)
330
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
331
- return this.BuildCRUDError(response, 'UpdateRecord', ctx.ObjectName);
508
+ const response = await this.MakeHTTPRequest(auth, url, obj.UpdateMethod ?? 'PATCH', headers, attributes);
509
+ if (response.Status >= 200 && response.Status < 300) {
510
+ return { Success: true, StatusCode: response.Status, ExternalID: externalID };
511
+ }
512
+ return { Success: false, StatusCode: response.Status, ExternalID: '', ErrorMessage: `UpdateRecord failed for "${objectName}": HTTP ${response.Status} — ${this.SafeBody(response)}` };
332
513
  }
333
- async DeleteRecord(ctx) {
334
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
335
- const headers = this.BuildHeaders(auth);
336
- const url = `${auth.InstanceURL}/services/data/${auth.ApiVersion}/sobjects/${ctx.ObjectName}/${ctx.ExternalID}`;
337
- const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
338
- if (response.Status === 204 || (response.Status >= 200 && response.Status < 300))
339
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
340
- return this.BuildCRUDError(response, 'DeleteRecord', ctx.ObjectName);
514
+ /**
515
+ * Nimble Fuse INBOUND upsert — POST `/services/apexrest/NUINT/NUIntegrationService` with the
516
+ * `{Request: {Name, "Authentication Key", InboundRecords:[{Fields:{...}}]}}` envelope. Parses the
517
+ * per-record `InboundResults` for partial success, routes the single-record create through
518
+ * BuildCreatedResult, and fails LOUDLY on an empty SalesforceId.
519
+ */
520
+ async FuseUpsert(contextUser, ci, obj, objectName, attributes, forUpdate) {
521
+ const auth = await this.Authenticate(ci, contextUser);
522
+ const cfg = this.ReadAccessConfig(obj);
523
+ const settingName = cfg.InboundSettingName;
524
+ if (!settingName) {
525
+ return { Success: false, StatusCode: 0, ErrorMessage: `Fuse upsert for "${objectName}" requires Configuration.InboundSettingName (the admin-configured Integration-Setting name); none is set.` };
526
+ }
527
+ const authKey = (auth.Config.AccessToken ? undefined : auth.Config.ClientSecret) ?? auth.Token ?? '';
528
+ const envelope = {
529
+ Request: {
530
+ Name: settingName,
531
+ 'Authentication Key': authKey,
532
+ InboundRecords: [{ Fields: attributes }],
533
+ },
534
+ };
535
+ const url = `${this.GetBaseURL(ci, auth)}${FUSE_PATH}`;
536
+ const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
537
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, envelope);
538
+ if (response.Status < 200 || response.Status >= 300) {
539
+ return { Success: false, StatusCode: response.Status, ErrorMessage: `Fuse upsert failed for "${objectName}": HTTP ${response.Status} — ${this.SafeBody(response)}` };
540
+ }
541
+ const body = response.Body;
542
+ const result = (body.InboundResults ?? [])[0];
543
+ const envelopeError = this.JoinErrors(body.ErrorMessages);
544
+ if (!result) {
545
+ return { Success: false, StatusCode: response.Status, ErrorMessage: `Fuse upsert for "${objectName}" returned no InboundResults${envelopeError ? ` — ${envelopeError}` : ''}.` };
546
+ }
547
+ const recordError = this.JoinErrors(result.ErrorMessages ?? result.ErrorMessage);
548
+ if (result.Success === false || recordError) {
549
+ return { Success: false, StatusCode: response.Status, ExternalID: '', ErrorMessage: `Fuse upsert for "${objectName}" failed for ExternalId="${result.ExternalId ?? ''}": ${recordError || envelopeError || body.Message || 'unknown error'}` };
550
+ }
551
+ // A 2xx with no SalesforceId is a silent loss — fail LOUDLY via BuildCreatedResult.
552
+ if (!forUpdate) {
553
+ return this.BuildCreatedResult(result.SalesforceId, response.Status, objectName);
554
+ }
555
+ const sfid = result.SalesforceId == null ? '' : String(result.SalesforceId).trim();
556
+ if (sfid.length === 0) {
557
+ return { Success: false, StatusCode: response.Status, ErrorMessage: `Fuse upsert (update) for "${objectName}" returned HTTP ${response.Status} but no SalesforceId — treating as a failure.` };
558
+ }
559
+ return { Success: true, StatusCode: response.Status, ExternalID: sfid };
560
+ }
561
+ JoinErrors(errs) {
562
+ if (!errs)
563
+ return '';
564
+ return Array.isArray(errs) ? errs.filter(Boolean).join('; ') : errs;
341
565
  }
342
- BuildCRUDError(response, op, obj) {
343
- const bodyStr = typeof response.Body === 'string' ? response.Body : JSON.stringify(response.Body);
344
- return { Success: false, ExternalID: '', StatusCode: response.Status, ErrorMessage: `${op} failed for ${obj}: HTTP ${response.Status} — ${bodyStr?.substring(0, 300)}` };
566
+ /** True when the object's write door is the Fuse inbound upsert (vs Salesforce REST sObject CRUD). */
567
+ IsFuseWritePath(obj) {
568
+ return (obj.CreateAPIPath ?? '').includes(FUSE_PATH) || (obj.UpdateAPIPath ?? '').includes(FUSE_PATH);
569
+ }
570
+ /**
571
+ * Resolve a Salesforce-REST path template, substituting `{apiVersion}` (bare version), `{ObjectName}`,
572
+ * and `{Id}`/`{ExternalID}`. The contract's SF-REST paths are root-relative (start at /services/...),
573
+ * so we prefix the instance origin only.
574
+ */
575
+ ResolveSFRestPath(auth, pathTemplate, objectName, externalID) {
576
+ let path = pathTemplate
577
+ .replace(/\{apiVersion\}/g, auth.ApiVersion)
578
+ .replace(/\{ObjectName\}/g, encodeURIComponent(objectName));
579
+ if (externalID !== undefined) {
580
+ const enc = encodeURIComponent(externalID);
581
+ path = path.replace(/\{Id\}/g, enc).replace(/\{ID\}/g, enc).replace(/\{id\}/g, enc).replace(/\{ExternalID\}/g, enc);
582
+ }
583
+ return `${this.Origin(auth)}${path}`;
584
+ }
585
+ /**
586
+ * Build an ExternalRecord from a raw vendor record. The FULL record flows into `Fields` (custom-column
587
+ * pass-through contract M1–M4) — the ONLY removed key is the sanctioned SF `attributes` envelope blob
588
+ * declared in {@link ExcludedSourceKeys}. The PK field supplies ExternalID; the watermark field supplies
589
+ * ModifiedAt. (FetchSOQL/FetchLMS override the base fetch, so we hand-build records here.)
590
+ */
591
+ RawToExternalRecord(raw, obj, objectType, watermarkField, pkField) {
592
+ const transformed = this.TransformRecord(raw, obj, this.GetCachedFields(obj.ID));
593
+ const excluded = new Set(this.ExcludedSourceKeys(objectType));
594
+ const fields = {};
595
+ for (const [key, value] of Object.entries(transformed)) {
596
+ if (excluded.has(key))
597
+ continue;
598
+ fields[key] = value;
599
+ }
600
+ const modRaw = raw[watermarkField];
601
+ return {
602
+ ExternalID: String(raw[pkField] ?? ''),
603
+ ObjectType: objectType,
604
+ Fields: fields,
605
+ ModifiedAt: typeof modRaw === 'string' ? new Date(modRaw) : undefined,
606
+ IsDeleted: raw['IsDeleted'] === true,
607
+ };
345
608
  }
346
- // ─── Headers + HTTP ────────────────────────────────────────────────
609
+ /** Parse the per-IO access Configuration (Family / SObjectType / AccessPath / Fuse setting names). */
610
+ ReadAccessConfig(obj) {
611
+ const raw = obj.Configuration;
612
+ if (!raw)
613
+ return {};
614
+ try {
615
+ return JSON.parse(raw);
616
+ }
617
+ catch {
618
+ return {};
619
+ }
620
+ }
621
+ // ── Headers + HTTP transport ─────────────────────────────────────────────
347
622
  BuildHeaders(auth) {
348
- return { 'Authorization': `Bearer ${auth.Token}`, 'Accept': 'application/json', 'User-Agent': 'MemberJunction-Integration/1.0' };
623
+ return {
624
+ Authorization: `Bearer ${auth.Token}`,
625
+ Accept: 'application/json',
626
+ 'User-Agent': 'MemberJunction-Integration/1.0',
627
+ };
349
628
  }
350
629
  async MakeHTTPRequest(_auth, url, method, headers, body) {
351
630
  await this.Throttle();
@@ -356,17 +635,18 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
356
635
  this.tokenCache = null;
357
636
  continue;
358
637
  }
359
- if (response.status === 429) {
360
- const d = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
361
- await this.Sleep(d);
638
+ if (response.status === 429 && attempt < MAX_RETRIES) {
639
+ const ra = Number(response.headers.get('retry-after'));
640
+ const wait = !isNaN(ra) && ra > 0 ? ra * 1000 : Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
641
+ await this.Sleep(wait);
362
642
  continue;
363
643
  }
364
644
  if (response.status >= 500 && attempt < MAX_RETRIES) {
365
645
  await this.Sleep(Math.min(1000 * Math.pow(2, attempt), 30_000));
366
646
  continue;
367
647
  }
368
- const responseBody = await this.ParseBody(response);
369
- return this.ToRESTResponse(response, responseBody);
648
+ const parsed = await this.ParseBody(response);
649
+ return this.ToRESTResponse(response, parsed);
370
650
  }
371
651
  throw new Error(`Nimble AMS request failed after ${MAX_RETRIES} retries: ${url}`);
372
652
  }
@@ -375,8 +655,14 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
375
655
  const tid = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
376
656
  try {
377
657
  const opts = { method, headers, signal: controller.signal };
378
- if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH'))
379
- opts.body = JSON.stringify(body);
658
+ if (body !== undefined && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
659
+ const serialized = JSON.stringify(body);
660
+ // Fuse OUTBOUND enforces a 3MB-per-call ceiling — guard the request body up front.
661
+ if (url.includes(FUSE_PATH) && Buffer.byteLength(serialized, 'utf8') > FUSE_MAX_BYTES_PER_CALL) {
662
+ throw new Error(`Nimble Fuse request body exceeds the ${FUSE_MAX_BYTES_PER_CALL}-byte (3MB) per-call limit.`);
663
+ }
664
+ opts.body = serialized;
665
+ }
380
666
  return await fetch(url, opts);
381
667
  }
382
668
  catch (err) {
@@ -388,22 +674,30 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
388
674
  clearTimeout(tid);
389
675
  }
390
676
  }
391
- async ParseBody(r) { const ct = r.headers.get('content-type') ?? ''; return ct.includes('json') ? r.json() : r.text(); }
392
- ToRESTResponse(r, body) { const h = {}; r.headers.forEach((v, k) => { h[k.toLowerCase()] = v; }); return { Status: r.status, Body: body, Headers: h }; }
393
- async Throttle() { const e = Date.now() - this.lastRequestTime; if (e < MIN_REQUEST_INTERVAL_MS)
394
- await this.Sleep(MIN_REQUEST_INTERVAL_MS - e); }
395
- Sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
396
- GetDefaultFieldMappings(objectName) {
397
- const obj = NIMBLE_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
398
- if (!obj)
399
- return [{ SourceFieldName: 'Id', DestinationFieldName: 'Id', IsKeyField: true }];
400
- return obj.Fields.map(f => ({ SourceFieldName: f.Name, DestinationFieldName: f.Name, IsKeyField: f.IsPrimaryKey }));
677
+ async ParseBody(r) {
678
+ const ct = r.headers.get('content-type') ?? '';
679
+ return ct.includes('json') ? r.json().catch(() => null) : r.text();
680
+ }
681
+ ToRESTResponse(r, body) {
682
+ const h = {};
683
+ r.headers.forEach((v, k) => { h[k.toLowerCase()] = v; });
684
+ return { Status: r.status, Body: body, Headers: h };
401
685
  }
686
+ SafeBody(r) {
687
+ const s = typeof r.Body === 'string' ? r.Body : JSON.stringify(r.Body);
688
+ return (s ?? '').substring(0, 300);
689
+ }
690
+ async Throttle() {
691
+ const elapsed = Date.now() - this.lastRequestTime;
692
+ if (elapsed < MIN_REQUEST_INTERVAL_MS)
693
+ await this.Sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
694
+ }
695
+ Sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
402
696
  };
403
697
  NimbleAMSConnector = __decorate([
404
698
  RegisterClass(BaseIntegrationConnector, 'NimbleAMSConnector')
405
699
  ], NimbleAMSConnector);
406
700
  export { NimbleAMSConnector };
407
- // Tree-shaking prevention — REQUIRED
701
+ // Tree-shaking prevention — REQUIRED so @RegisterClass survives bundling.
408
702
  export function LoadNimbleAMSConnector() { }
409
703
  //# sourceMappingURL=NimbleAMSConnector.js.map