@memberjunction/integration-connectors 5.14.0 → 5.16.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/README.md +241 -50
- package/dist/QuickBooksConnector.d.ts +92 -0
- package/dist/QuickBooksConnector.d.ts.map +1 -0
- package/dist/QuickBooksConnector.js +693 -0
- package/dist/QuickBooksConnector.js.map +1 -0
- package/dist/SageIntacctConnector.d.ts +179 -0
- package/dist/SageIntacctConnector.d.ts.map +1 -0
- package/dist/SageIntacctConnector.js +1008 -0
- package/dist/SageIntacctConnector.js.map +1 -0
- package/dist/SalesforceConnector.d.ts +233 -18
- package/dist/SalesforceConnector.d.ts.map +1 -1
- package/dist/SalesforceConnector.js +1136 -32
- package/dist/SalesforceConnector.js.map +1 -1
- package/dist/generate-integration-actions.d.ts +9 -5
- package/dist/generate-integration-actions.d.ts.map +1 -1
- package/dist/generate-integration-actions.js +92 -22
- package/dist/generate-integration-actions.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
|
@@ -4,67 +4,1171 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
4
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
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
|
+
import jwt from 'jsonwebtoken';
|
|
7
8
|
import { RegisterClass } from '@memberjunction/global';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
9
|
+
import { Metadata } from '@memberjunction/core';
|
|
10
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
11
|
+
// ─── Constants ────────────────────────────────────────────────────────
|
|
12
|
+
const DEFAULT_API_VERSION = '61.0';
|
|
13
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
14
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
15
|
+
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 100;
|
|
16
|
+
const DEFAULT_BATCH_SIZE = 2000;
|
|
17
|
+
/** Token lifetime — SF tokens last ~2 hours; refresh at 80% (96 min) */
|
|
18
|
+
const TOKEN_LIFETIME_MS = 2 * 60 * 60 * 1000;
|
|
19
|
+
const TOKEN_REFRESH_THRESHOLD = 0.8;
|
|
20
|
+
/** Governor limit thresholds */
|
|
21
|
+
const GOVERNOR_THROTTLE_THRESHOLD = 0.8;
|
|
22
|
+
const GOVERNOR_PAUSE_THRESHOLD = 0.95;
|
|
23
|
+
/** SF compound field types to skip during discovery */
|
|
24
|
+
const COMPOUND_FIELD_TYPES = new Set(['address', 'location']);
|
|
25
|
+
/** SF system fields that are always read-only */
|
|
26
|
+
const SYSTEM_READ_ONLY_FIELDS = new Set([
|
|
27
|
+
'Id', 'CreatedDate', 'CreatedById', 'LastModifiedDate', 'LastModifiedById',
|
|
28
|
+
'SystemModstamp', 'IsDeleted', 'LastActivityDate', 'LastViewedDate',
|
|
29
|
+
'LastReferencedDate', 'MasterRecordId',
|
|
30
|
+
]);
|
|
31
|
+
/** Fields always included in SOQL SELECT */
|
|
32
|
+
const REQUIRED_SOQL_FIELDS = ['Id', 'SystemModstamp', 'IsDeleted', 'LastModifiedById'];
|
|
33
|
+
// ─── Salesforce Object Metadata for Action Generation ─────────────────
|
|
34
|
+
const SALESFORCE_OBJECTS = [
|
|
35
|
+
{
|
|
36
|
+
Name: 'Account', DisplayName: 'Account',
|
|
37
|
+
Description: 'A company, organization, or consumer in Salesforce CRM', SupportsWrite: true,
|
|
38
|
+
Fields: [
|
|
39
|
+
{ Name: 'Name', DisplayName: 'Account Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account name' },
|
|
40
|
+
{ Name: 'Industry', DisplayName: 'Industry', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account industry' },
|
|
41
|
+
{ Name: 'Phone', DisplayName: 'Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account phone number' },
|
|
42
|
+
{ Name: 'Website', DisplayName: 'Website', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account website URL' },
|
|
43
|
+
{ Name: 'BillingStreet', DisplayName: 'Billing Street', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Billing street address' },
|
|
44
|
+
{ Name: 'BillingCity', DisplayName: 'Billing City', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Billing city' },
|
|
45
|
+
{ Name: 'BillingState', DisplayName: 'Billing State', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Billing state/province' },
|
|
46
|
+
{ Name: 'BillingPostalCode', DisplayName: 'Billing Postal Code', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Billing postal code' },
|
|
47
|
+
{ Name: 'BillingCountry', DisplayName: 'Billing Country', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Billing country' },
|
|
48
|
+
{ Name: 'Description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account description' },
|
|
49
|
+
{ Name: 'Type', DisplayName: 'Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Account type' },
|
|
50
|
+
{ Name: 'AnnualRevenue', DisplayName: 'Annual Revenue', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Annual revenue' },
|
|
51
|
+
{ Name: 'NumberOfEmployees', DisplayName: 'Employees', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Number of employees' },
|
|
52
|
+
{ Name: 'Id', DisplayName: 'Account ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
Name: 'Contact', DisplayName: 'Contact',
|
|
57
|
+
Description: 'A person associated with an account in Salesforce CRM', SupportsWrite: true,
|
|
58
|
+
Fields: [
|
|
59
|
+
{ Name: 'Email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact email address' },
|
|
60
|
+
{ Name: 'FirstName', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact first name' },
|
|
61
|
+
{ Name: 'LastName', DisplayName: 'Last Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact last name' },
|
|
62
|
+
{ Name: 'Phone', DisplayName: 'Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact phone number' },
|
|
63
|
+
{ Name: 'MobilePhone', DisplayName: 'Mobile Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Mobile phone number' },
|
|
64
|
+
{ Name: 'Title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact title' },
|
|
65
|
+
{ Name: 'Department', DisplayName: 'Department', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact department' },
|
|
66
|
+
{ Name: 'MailingStreet', DisplayName: 'Mailing Street', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Mailing street address' },
|
|
67
|
+
{ Name: 'MailingCity', DisplayName: 'Mailing City', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Mailing city' },
|
|
68
|
+
{ Name: 'MailingState', DisplayName: 'Mailing State', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Mailing state/province' },
|
|
69
|
+
{ Name: 'MailingPostalCode', DisplayName: 'Mailing Postal Code', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Mailing postal code' },
|
|
70
|
+
{ Name: 'MailingCountry', DisplayName: 'Mailing Country', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Mailing country' },
|
|
71
|
+
{ Name: 'AccountId', DisplayName: 'Account ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Parent account ID' },
|
|
72
|
+
{ Name: 'Id', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
Name: 'Lead', DisplayName: 'Lead',
|
|
77
|
+
Description: 'A prospective customer/lead in Salesforce CRM', SupportsWrite: true,
|
|
78
|
+
Fields: [
|
|
79
|
+
{ Name: 'Email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead email address' },
|
|
80
|
+
{ Name: 'FirstName', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead first name' },
|
|
81
|
+
{ Name: 'LastName', DisplayName: 'Last Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead last name' },
|
|
82
|
+
{ Name: 'Company', DisplayName: 'Company', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead company name' },
|
|
83
|
+
{ Name: 'Phone', DisplayName: 'Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead phone number' },
|
|
84
|
+
{ Name: 'Title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead title' },
|
|
85
|
+
{ Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead status' },
|
|
86
|
+
{ Name: 'Id', DisplayName: 'Lead ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
Name: 'Opportunity', DisplayName: 'Opportunity',
|
|
91
|
+
Description: 'A sales opportunity in Salesforce CRM', SupportsWrite: true,
|
|
92
|
+
Fields: [
|
|
93
|
+
{ Name: 'Name', DisplayName: 'Opportunity Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Opportunity name' },
|
|
94
|
+
{ Name: 'Amount', DisplayName: 'Amount', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Opportunity amount' },
|
|
95
|
+
{ Name: 'StageName', DisplayName: 'Stage', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Current stage' },
|
|
96
|
+
{ Name: 'CloseDate', DisplayName: 'Close Date', Type: 'date', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Expected close date' },
|
|
97
|
+
{ Name: 'AccountId', DisplayName: 'Account ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Parent account ID' },
|
|
98
|
+
{ Name: 'Description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Opportunity description' },
|
|
99
|
+
{ Name: 'Id', DisplayName: 'Opportunity ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
Name: 'Task', DisplayName: 'Task',
|
|
104
|
+
Description: 'A task or to-do item in Salesforce', SupportsWrite: true,
|
|
105
|
+
Fields: [
|
|
106
|
+
{ Name: 'Subject', DisplayName: 'Subject', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task subject' },
|
|
107
|
+
{ Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task status' },
|
|
108
|
+
{ Name: 'Priority', DisplayName: 'Priority', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task priority' },
|
|
109
|
+
{ Name: 'WhoId', DisplayName: 'Who ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Related Contact or Lead ID' },
|
|
110
|
+
{ Name: 'WhatId', DisplayName: 'What ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Related Account, Opportunity, etc. ID' },
|
|
111
|
+
{ Name: 'Id', DisplayName: 'Task ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
Name: 'Event', DisplayName: 'Event',
|
|
116
|
+
Description: 'A calendar event in Salesforce', SupportsWrite: true,
|
|
117
|
+
Fields: [
|
|
118
|
+
{ Name: 'Subject', DisplayName: 'Subject', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event subject' },
|
|
119
|
+
{ Name: 'StartDateTime', DisplayName: 'Start', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event start date/time' },
|
|
120
|
+
{ Name: 'EndDateTime', DisplayName: 'End', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event end date/time' },
|
|
121
|
+
{ Name: 'WhoId', DisplayName: 'Who ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Related Contact or Lead ID' },
|
|
122
|
+
{ Name: 'Id', DisplayName: 'Event ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
Name: 'Case', DisplayName: 'Case',
|
|
127
|
+
Description: 'A support case in Salesforce', SupportsWrite: true,
|
|
128
|
+
Fields: [
|
|
129
|
+
{ Name: 'Subject', DisplayName: 'Subject', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Case subject' },
|
|
130
|
+
{ Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Case status' },
|
|
131
|
+
{ Name: 'Priority', DisplayName: 'Priority', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Case priority' },
|
|
132
|
+
{ Name: 'AccountId', DisplayName: 'Account ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Parent account ID' },
|
|
133
|
+
{ Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Related contact ID' },
|
|
134
|
+
{ Name: 'Description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Case description' },
|
|
135
|
+
{ Name: 'Id', DisplayName: 'Case ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
Name: 'Campaign', DisplayName: 'Campaign',
|
|
140
|
+
Description: 'A marketing campaign in Salesforce', SupportsWrite: true,
|
|
141
|
+
Fields: [
|
|
142
|
+
{ Name: 'Name', DisplayName: 'Campaign Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Campaign name' },
|
|
143
|
+
{ Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Campaign status' },
|
|
144
|
+
{ Name: 'Type', DisplayName: 'Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Campaign type' },
|
|
145
|
+
{ Name: 'StartDate', DisplayName: 'Start Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Campaign start date' },
|
|
146
|
+
{ Name: 'EndDate', DisplayName: 'End Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Campaign end date' },
|
|
147
|
+
{ Name: 'Id', DisplayName: 'Campaign ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
Name: 'User', DisplayName: 'User',
|
|
152
|
+
Description: 'A Salesforce user (reference only, not writable)', SupportsWrite: false,
|
|
153
|
+
IncludeInActionGeneration: false,
|
|
154
|
+
Fields: [
|
|
155
|
+
{ Name: 'Username', DisplayName: 'Username', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User login name' },
|
|
156
|
+
{ Name: 'Name', DisplayName: 'Full Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Full name' },
|
|
157
|
+
{ Name: 'Email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User email' },
|
|
158
|
+
{ Name: 'Id', DisplayName: 'User ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce user ID' },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
// ─── SalesforceConnector ──────────────────────────────────────────────
|
|
16
163
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
164
|
+
* Production Salesforce CRM connector using the Salesforce REST API v61.0.
|
|
165
|
+
*
|
|
166
|
+
* Supports:
|
|
167
|
+
* - OAuth 2.0 JWT Bearer Token authentication (RS256)
|
|
168
|
+
* - Live schema discovery via SF Describe API
|
|
169
|
+
* - SOQL-based incremental sync with SystemModstamp watermarks
|
|
170
|
+
* - Full CRUD (Create, Update, Delete) via SObject REST API
|
|
171
|
+
* - SOQL/SOSL search support
|
|
172
|
+
* - Comprehensive SF error code handling and governor limit management
|
|
173
|
+
*
|
|
174
|
+
* Extends BaseRESTIntegrationConnector but overrides FetchChanges entirely
|
|
175
|
+
* because Salesforce uses SOQL queries instead of standard REST list endpoints.
|
|
20
176
|
*/
|
|
21
|
-
let SalesforceConnector = class SalesforceConnector extends
|
|
177
|
+
let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationConnector {
|
|
178
|
+
constructor() {
|
|
179
|
+
super(...arguments);
|
|
180
|
+
// ── Token cache ─────────────────────────────────────────────────
|
|
181
|
+
this.cachedAuth = null;
|
|
182
|
+
this.tokenObtainedAt = 0;
|
|
183
|
+
// ── Rate limit / governor tracking ──────────────────────────────
|
|
184
|
+
this.lastRequestTime = 0;
|
|
185
|
+
this.governorState = { CurrentUsage: 0, DailyLimit: 15000, LastUpdated: 0 };
|
|
186
|
+
// ── Pagination state for SOQL queryMore ─────────────────────────
|
|
187
|
+
this.queryLocator = null;
|
|
188
|
+
// ── Config ──────────────────────────────────────────────────────
|
|
189
|
+
this._config = null;
|
|
190
|
+
}
|
|
191
|
+
get effectiveMaxRetries() { return this._config?.MaxRetries ?? DEFAULT_MAX_RETRIES; }
|
|
192
|
+
get effectiveRequestTimeoutMs() { return this._config?.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; }
|
|
193
|
+
get effectiveMinRequestIntervalMs() { return this._config?.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS; }
|
|
194
|
+
get effectiveBatchSize() { return this._config?.DefaultBatchSize ?? DEFAULT_BATCH_SIZE; }
|
|
195
|
+
// ─── Capability Getters ──────────────────────────────────────────
|
|
196
|
+
get SupportsCreate() { return true; }
|
|
197
|
+
get SupportsUpdate() { return true; }
|
|
198
|
+
get SupportsDelete() { return true; }
|
|
199
|
+
get SupportsSearch() { return true; }
|
|
200
|
+
get IntegrationName() { return 'Salesforce'; }
|
|
201
|
+
// ─── Action Metadata ─────────────────────────────────────────────
|
|
202
|
+
GetIntegrationObjects() {
|
|
203
|
+
return SALESFORCE_OBJECTS;
|
|
204
|
+
}
|
|
205
|
+
GetActionGeneratorConfig() {
|
|
206
|
+
const config = super.GetActionGeneratorConfig();
|
|
207
|
+
if (!config)
|
|
208
|
+
return null;
|
|
209
|
+
config.IconClass = 'fa-brands fa-salesforce';
|
|
210
|
+
return config;
|
|
211
|
+
}
|
|
212
|
+
// ─── TestConnection ──────────────────────────────────────────────
|
|
213
|
+
async TestConnection(companyIntegration, contextUser) {
|
|
214
|
+
try {
|
|
215
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
216
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/`;
|
|
217
|
+
const headers = this.BuildHeaders(auth);
|
|
218
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
219
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
220
|
+
return {
|
|
221
|
+
Success: true,
|
|
222
|
+
Message: `Successfully connected to Salesforce (${auth.InstanceUrl})`,
|
|
223
|
+
ServerVersion: `Salesforce REST API v${auth.ApiVersion}`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
Success: false,
|
|
228
|
+
Message: `Salesforce API returned ${response.Status}: ${this.PreviewBody(response.Body)}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
233
|
+
return { Success: false, Message: `Connection failed: ${message}` };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// ─── Discovery (Live from SF Describe API) ───────────────────────
|
|
237
|
+
/**
|
|
238
|
+
* Discovers available objects by calling the SF SObjects API.
|
|
239
|
+
* Returns all queryable objects including custom objects (__c).
|
|
240
|
+
*/
|
|
241
|
+
async DiscoverObjects(companyIntegration, contextUser) {
|
|
242
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
243
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/`;
|
|
244
|
+
const headers = this.BuildHeaders(auth);
|
|
245
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
246
|
+
this.ValidateResponse(response, url);
|
|
247
|
+
const body = response.Body;
|
|
248
|
+
const sobjects = body.sobjects ?? [];
|
|
249
|
+
return sobjects
|
|
250
|
+
.filter(obj => obj.queryable)
|
|
251
|
+
.map(obj => ({
|
|
252
|
+
Name: obj.name,
|
|
253
|
+
Label: obj.label,
|
|
254
|
+
Description: obj.custom ? `Custom object: ${obj.label}` : undefined,
|
|
255
|
+
SupportsIncrementalSync: true,
|
|
256
|
+
SupportsWrite: obj.createable || obj.updateable,
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Discovers fields on a specific SF object via the Describe API.
|
|
261
|
+
* Skips compound fields (address, location) — their component fields are included individually.
|
|
262
|
+
*/
|
|
263
|
+
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
264
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
265
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${objectName}/describe`;
|
|
266
|
+
const headers = this.BuildHeaders(auth);
|
|
267
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
268
|
+
this.ValidateResponse(response, url);
|
|
269
|
+
const body = response.Body;
|
|
270
|
+
const fields = body.fields ?? [];
|
|
271
|
+
return fields
|
|
272
|
+
.filter(f => !COMPOUND_FIELD_TYPES.has(f.type))
|
|
273
|
+
.map(f => this.MapSFFieldToSchema(f));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Full schema introspection — builds object graph with relationships.
|
|
277
|
+
*/
|
|
278
|
+
async IntrospectSchema(companyIntegration, contextUser) {
|
|
279
|
+
const objects = await this.DiscoverObjects(companyIntegration, contextUser);
|
|
280
|
+
const result = { Objects: [] };
|
|
281
|
+
for (const obj of objects) {
|
|
282
|
+
try {
|
|
283
|
+
const sourceObj = await this.BuildSourceObjectFromDescribe(companyIntegration, contextUser, obj.Name);
|
|
284
|
+
result.Objects.push(sourceObj);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
288
|
+
console.warn(`[Salesforce] Skipping "${obj.Name}" during introspection: ${msg}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
// ─── FetchChanges (SOQL-based — overrides base class entirely) ───
|
|
22
294
|
/**
|
|
23
|
-
* Fetches
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* @returns Batch of Salesforce records with pagination info
|
|
295
|
+
* Fetches changed records using SOQL queries with SystemModstamp watermarks.
|
|
296
|
+
* Completely overrides the base class REST pagination because SF uses
|
|
297
|
+
* SOQL + queryMore, not standard REST list endpoints.
|
|
27
298
|
*/
|
|
28
299
|
async FetchChanges(ctx) {
|
|
29
|
-
|
|
300
|
+
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
301
|
+
const batchSize = ctx.BatchSize || this.effectiveBatchSize;
|
|
302
|
+
// If we have a queryLocator from a previous call, use queryMore
|
|
303
|
+
if (this.queryLocator && ctx.CurrentCursor) {
|
|
304
|
+
return this.FetchNextPage(auth, ctx.CurrentCursor);
|
|
305
|
+
}
|
|
306
|
+
// Build and execute SOQL query
|
|
307
|
+
const fields = await this.GetQueryableFieldNames(auth, ctx.ObjectName);
|
|
308
|
+
const soql = this.BuildSOQLQuery(ctx.ObjectName, fields, ctx.WatermarkValue, batchSize, true);
|
|
309
|
+
return this.ExecuteSOQLQuery(auth, soql, ctx.ObjectName);
|
|
30
310
|
}
|
|
311
|
+
// ─── CRUD Operations ─────────────────────────────────────────────
|
|
31
312
|
/**
|
|
32
|
-
*
|
|
33
|
-
* @param objectName - Salesforce object name (e.g., "Contact")
|
|
34
|
-
* @param _entityName - Target MJ entity name (e.g., "Contacts")
|
|
35
|
-
* @returns Array of default field mappings
|
|
313
|
+
* Creates a new record in Salesforce.
|
|
36
314
|
*/
|
|
315
|
+
async CreateRecord(ctx) {
|
|
316
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
317
|
+
const contextUser = ctx.ContextUser;
|
|
318
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
319
|
+
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
320
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${ctx.ObjectName}/`;
|
|
321
|
+
const body = this.StripReadOnlyFields(ctx.Attributes);
|
|
322
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
323
|
+
if (response.Status === 201 || (response.Status >= 200 && response.Status < 300)) {
|
|
324
|
+
const created = response.Body;
|
|
325
|
+
return {
|
|
326
|
+
Success: true,
|
|
327
|
+
ExternalID: created.id ?? '',
|
|
328
|
+
StatusCode: response.Status,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return this.BuildCRUDError(response, 'CreateRecord', ctx.ObjectName);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Updates an existing record in Salesforce (PATCH — only changed fields).
|
|
335
|
+
*/
|
|
336
|
+
async UpdateRecord(ctx) {
|
|
337
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
338
|
+
const contextUser = ctx.ContextUser;
|
|
339
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
340
|
+
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
341
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${ctx.ObjectName}/${ctx.ExternalID}`;
|
|
342
|
+
const body = this.StripReadOnlyFields(ctx.Attributes);
|
|
343
|
+
const response = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, body);
|
|
344
|
+
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
345
|
+
return {
|
|
346
|
+
Success: true,
|
|
347
|
+
ExternalID: ctx.ExternalID,
|
|
348
|
+
StatusCode: response.Status,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return this.HandleWriteError(response, 'UpdateRecord', ctx.ObjectName, ctx.ExternalID, auth, headers, url, body);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Deletes a record from Salesforce.
|
|
355
|
+
*/
|
|
356
|
+
async DeleteRecord(ctx) {
|
|
357
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
358
|
+
const contextUser = ctx.ContextUser;
|
|
359
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
360
|
+
const headers = this.BuildHeaders(auth);
|
|
361
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${ctx.ObjectName}/${ctx.ExternalID}`;
|
|
362
|
+
const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
|
|
363
|
+
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
364
|
+
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
|
|
365
|
+
}
|
|
366
|
+
// ENTITY_IS_DELETED means it's already gone — treat as success
|
|
367
|
+
if (this.HasSFErrorCode(response, 'ENTITY_IS_DELETED')) {
|
|
368
|
+
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: 200 };
|
|
369
|
+
}
|
|
370
|
+
return this.BuildCRUDError(response, 'DeleteRecord', ctx.ObjectName);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Retrieves a single record by its Salesforce ID.
|
|
374
|
+
*/
|
|
375
|
+
async GetRecord(ctx) {
|
|
376
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
377
|
+
const contextUser = ctx.ContextUser;
|
|
378
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
379
|
+
const headers = this.BuildHeaders(auth);
|
|
380
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${ctx.ObjectName}/${ctx.ExternalID}`;
|
|
381
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
382
|
+
if (response.Status === 404)
|
|
383
|
+
return null;
|
|
384
|
+
this.ValidateResponse(response, url);
|
|
385
|
+
const raw = response.Body;
|
|
386
|
+
return this.RawToExternalRecord(raw, ctx.ObjectName);
|
|
387
|
+
}
|
|
388
|
+
// ─── Search (SOQL) ───────────────────────────────────────────────
|
|
389
|
+
/**
|
|
390
|
+
* Searches records using SOQL WHERE clauses built from the provided filters.
|
|
391
|
+
*/
|
|
392
|
+
async SearchRecords(ctx) {
|
|
393
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
394
|
+
const contextUser = ctx.ContextUser;
|
|
395
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
396
|
+
const fields = await this.GetQueryableFieldNames(auth, ctx.ObjectName);
|
|
397
|
+
const whereClause = this.BuildWhereClauseFromFilters(ctx.Filters);
|
|
398
|
+
const limit = ctx.PageSize ?? 100;
|
|
399
|
+
const offset = ctx.Page != null && ctx.Page > 1 ? (ctx.Page - 1) * limit : 0;
|
|
400
|
+
let soql = `SELECT ${fields.join(', ')} FROM ${ctx.ObjectName}`;
|
|
401
|
+
if (whereClause)
|
|
402
|
+
soql += ` WHERE ${whereClause}`;
|
|
403
|
+
if (ctx.Sort)
|
|
404
|
+
soql += ` ORDER BY ${ctx.Sort}`;
|
|
405
|
+
soql += ` LIMIT ${limit}`;
|
|
406
|
+
if (offset > 0)
|
|
407
|
+
soql += ` OFFSET ${offset}`;
|
|
408
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/query?q=${encodeURIComponent(soql)}`;
|
|
409
|
+
const headers = this.BuildHeaders(auth);
|
|
410
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
411
|
+
this.ValidateResponse(response, url);
|
|
412
|
+
const body = response.Body;
|
|
413
|
+
const records = (body.records ?? []).map(r => this.RawToExternalRecord(r, ctx.ObjectName));
|
|
414
|
+
return {
|
|
415
|
+
Records: records,
|
|
416
|
+
TotalCount: body.totalSize ?? records.length,
|
|
417
|
+
HasMore: !body.done,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// ─── Default Field Mappings ──────────────────────────────────────
|
|
37
421
|
GetDefaultFieldMappings(objectName, _entityName) {
|
|
38
422
|
switch (objectName) {
|
|
39
|
-
case 'Contact':
|
|
40
|
-
|
|
41
|
-
case '
|
|
42
|
-
|
|
423
|
+
case 'Contact': return this.GetContactMappings();
|
|
424
|
+
case 'Account': return this.GetAccountMappings();
|
|
425
|
+
case 'Lead': return this.GetLeadMappings();
|
|
426
|
+
default: return [];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
GetDefaultConfiguration() {
|
|
430
|
+
return {
|
|
431
|
+
DefaultSchemaName: 'Salesforce',
|
|
432
|
+
DefaultObjects: [
|
|
433
|
+
{
|
|
434
|
+
SourceObjectName: 'Account',
|
|
435
|
+
TargetTableName: 'SalesforceAccount',
|
|
436
|
+
TargetEntityName: 'Salesforce Account',
|
|
437
|
+
SyncEnabled: true,
|
|
438
|
+
FieldMappings: this.GetAccountMappings(),
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
SourceObjectName: 'Contact',
|
|
442
|
+
TargetTableName: 'SalesforceContact',
|
|
443
|
+
TargetEntityName: 'Salesforce Contact',
|
|
444
|
+
SyncEnabled: true,
|
|
445
|
+
FieldMappings: this.GetContactMappings(),
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
SourceObjectName: 'Lead',
|
|
449
|
+
TargetTableName: 'SalesforceLead',
|
|
450
|
+
TargetEntityName: 'Salesforce Lead',
|
|
451
|
+
SyncEnabled: true,
|
|
452
|
+
FieldMappings: this.GetLeadMappings(),
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
// ─── BaseRESTIntegrationConnector abstract implementations ───────
|
|
458
|
+
async Authenticate(companyIntegration, contextUser) {
|
|
459
|
+
// Return cached auth if still valid
|
|
460
|
+
if (this.cachedAuth && this.IsTokenValid()) {
|
|
461
|
+
return this.cachedAuth;
|
|
462
|
+
}
|
|
463
|
+
const credentials = await this.LoadCredentials(companyIntegration, contextUser);
|
|
464
|
+
const config = this.BuildConnectionConfig(credentials, companyIntegration);
|
|
465
|
+
this._config = config;
|
|
466
|
+
console.log(`[Salesforce] Authenticating via ${config.AuthFlow === 'client_credentials' ? 'Client Credentials' : 'JWT Bearer Token'}...`);
|
|
467
|
+
const tokenResponse = config.AuthFlow === 'client_credentials'
|
|
468
|
+
? await this.ExchangeClientCredentialsForToken(config)
|
|
469
|
+
: await this.ExchangeJWTForToken(config.TokenUrl ?? config.LoginUrl, this.BuildJWTAssertion(config));
|
|
470
|
+
this.cachedAuth = {
|
|
471
|
+
Token: tokenResponse.access_token,
|
|
472
|
+
InstanceUrl: tokenResponse.instance_url,
|
|
473
|
+
ApiVersion: config.ApiVersion,
|
|
474
|
+
Config: config,
|
|
475
|
+
};
|
|
476
|
+
this.tokenObtainedAt = Date.now();
|
|
477
|
+
console.log(`[Salesforce] Authenticated, instance: ${tokenResponse.instance_url}`);
|
|
478
|
+
return this.cachedAuth;
|
|
479
|
+
}
|
|
480
|
+
BuildHeaders(auth) {
|
|
481
|
+
return {
|
|
482
|
+
'Authorization': `Bearer ${auth.Token}`,
|
|
483
|
+
'Accept': 'application/json',
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
487
|
+
await this.ThrottleIfNeeded();
|
|
488
|
+
await this.CheckGovernorLimits();
|
|
489
|
+
const maxRetries = this.effectiveMaxRetries;
|
|
490
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
491
|
+
const response = await this.FetchWithTimeout(url, method, headers, body);
|
|
492
|
+
this.lastRequestTime = Date.now();
|
|
493
|
+
// Parse governor limits from response
|
|
494
|
+
this.ParseGovernorLimits(response.headers);
|
|
495
|
+
// Handle 401 — token expired, re-authenticate
|
|
496
|
+
if (response.status === 401 && attempt === 0) {
|
|
497
|
+
console.warn('[Salesforce] Token expired (401), re-authenticating...');
|
|
498
|
+
this.cachedAuth = null;
|
|
499
|
+
// Caller should re-authenticate; for now return the error
|
|
500
|
+
}
|
|
501
|
+
// Handle 429 — rate limited
|
|
502
|
+
if (response.status === 429) {
|
|
503
|
+
const delayMs = this.CalculateRetryDelay(attempt);
|
|
504
|
+
console.warn(`[Salesforce] Rate limited (429), retrying in ${delayMs}ms ` +
|
|
505
|
+
`(attempt ${attempt + 1}/${maxRetries})`);
|
|
506
|
+
await this.Sleep(delayMs);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
// Handle UNABLE_TO_LOCK_ROW — retry with backoff
|
|
510
|
+
if (response.status === 400) {
|
|
511
|
+
const responseBody = await this.SafeParseJSON(response);
|
|
512
|
+
if (this.IsLockRowError(responseBody) && attempt < maxRetries) {
|
|
513
|
+
const delayMs = this.CalculateRetryDelay(attempt);
|
|
514
|
+
console.warn(`[Salesforce] Row lock contention, retrying in ${delayMs}ms`);
|
|
515
|
+
await this.Sleep(delayMs);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
return this.BuildRESTResponse(response, responseBody);
|
|
519
|
+
}
|
|
520
|
+
// Handle 204 No Content (successful DELETE/PATCH)
|
|
521
|
+
if (response.status === 204) {
|
|
522
|
+
return { Status: 204, Body: {}, Headers: this.ExtractHeaders(response) };
|
|
523
|
+
}
|
|
524
|
+
const responseBody = await this.SafeParseJSON(response);
|
|
525
|
+
return this.BuildRESTResponse(response, responseBody);
|
|
526
|
+
}
|
|
527
|
+
throw new Error(`Salesforce API request failed after ${maxRetries} retries: ${url}`);
|
|
528
|
+
}
|
|
529
|
+
NormalizeResponse(rawBody, _responseDataKey) {
|
|
530
|
+
// SF SOQL responses have records at body.records
|
|
531
|
+
const body = rawBody;
|
|
532
|
+
return (body.records ?? []);
|
|
533
|
+
}
|
|
534
|
+
ExtractPaginationInfo(rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
|
|
535
|
+
// Not used — we override FetchChanges entirely for SOQL
|
|
536
|
+
const body = rawBody;
|
|
537
|
+
return {
|
|
538
|
+
HasMore: !body.done,
|
|
539
|
+
NextCursor: body.nextRecordsUrl ?? undefined,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
GetBaseURL(_companyIntegration, auth) {
|
|
543
|
+
const sfAuth = auth;
|
|
544
|
+
return sfAuth.InstanceUrl;
|
|
545
|
+
}
|
|
546
|
+
// ─── SOQL Query Engine ───────────────────────────────────────────
|
|
547
|
+
/**
|
|
548
|
+
* Builds a SOQL query string with optional watermark filter.
|
|
549
|
+
*/
|
|
550
|
+
BuildSOQLQuery(objectName, fields, watermarkValue, batchSize, includeDeleted) {
|
|
551
|
+
const dedupedFields = [...new Set([...REQUIRED_SOQL_FIELDS, ...fields])];
|
|
552
|
+
let soql = `SELECT ${dedupedFields.join(', ')} FROM ${objectName}`;
|
|
553
|
+
if (watermarkValue) {
|
|
554
|
+
soql += ` WHERE SystemModstamp > ${this.FormatSOQLDateTime(watermarkValue)}`;
|
|
555
|
+
}
|
|
556
|
+
soql += ' ORDER BY SystemModstamp ASC';
|
|
557
|
+
soql += ` LIMIT ${batchSize}`;
|
|
558
|
+
return soql;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Executes a SOQL query and returns records as a FetchBatchResult.
|
|
562
|
+
*/
|
|
563
|
+
async ExecuteSOQLQuery(auth, soql, objectName) {
|
|
564
|
+
// Use queryAll to include deleted records (IsDeleted=true)
|
|
565
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/queryAll?q=${encodeURIComponent(soql)}`;
|
|
566
|
+
const headers = this.BuildHeaders(auth);
|
|
567
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
568
|
+
this.ValidateResponse(response, url);
|
|
569
|
+
return this.ParseSOQLResponse(response.Body, objectName);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Fetches the next page of results using a queryMore locator.
|
|
573
|
+
*/
|
|
574
|
+
async FetchNextPage(auth, queryLocator) {
|
|
575
|
+
// queryLocator is a relative URL like /services/data/v61.0/query/01gxx...
|
|
576
|
+
const url = `${auth.InstanceUrl}${queryLocator}`;
|
|
577
|
+
const headers = this.BuildHeaders(auth);
|
|
578
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
579
|
+
this.ValidateResponse(response, url);
|
|
580
|
+
const body = response.Body;
|
|
581
|
+
// Extract object name from the first record's attributes
|
|
582
|
+
const firstRecord = body.records?.[0];
|
|
583
|
+
const attrs = firstRecord?.['attributes'];
|
|
584
|
+
const objectName = attrs?.type ?? 'Unknown';
|
|
585
|
+
return this.ParseSOQLResponse(body, objectName);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Parses a SOQL query response into a FetchBatchResult.
|
|
589
|
+
*/
|
|
590
|
+
ParseSOQLResponse(body, objectName) {
|
|
591
|
+
const records = (body.records ?? []).map(r => {
|
|
592
|
+
const raw = r;
|
|
593
|
+
const record = this.RawToExternalRecord(raw, objectName);
|
|
594
|
+
record.IsDeleted = raw['IsDeleted'] === true;
|
|
595
|
+
record.ModifiedAt = raw['SystemModstamp']
|
|
596
|
+
? new Date(raw['SystemModstamp'])
|
|
597
|
+
: undefined;
|
|
598
|
+
return record;
|
|
599
|
+
});
|
|
600
|
+
// Compute new watermark from the max SystemModstamp in this batch
|
|
601
|
+
const newWatermark = this.ExtractMaxWatermark(body.records ?? []);
|
|
602
|
+
return {
|
|
603
|
+
Records: records,
|
|
604
|
+
HasMore: !body.done,
|
|
605
|
+
NewWatermarkValue: newWatermark ?? undefined,
|
|
606
|
+
NextCursor: body.nextRecordsUrl ?? undefined,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Extracts the maximum SystemModstamp value from a batch of records.
|
|
611
|
+
*/
|
|
612
|
+
ExtractMaxWatermark(records) {
|
|
613
|
+
let maxTimestamp = null;
|
|
614
|
+
for (const record of records) {
|
|
615
|
+
const raw = record;
|
|
616
|
+
const stamp = raw['SystemModstamp'];
|
|
617
|
+
if (stamp && (!maxTimestamp || stamp > maxTimestamp)) {
|
|
618
|
+
maxTimestamp = stamp;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return maxTimestamp;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Gets queryable field names for a SF object via the Describe API.
|
|
625
|
+
*/
|
|
626
|
+
async GetQueryableFieldNames(auth, objectName) {
|
|
627
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${objectName}/describe`;
|
|
628
|
+
const headers = this.BuildHeaders(auth);
|
|
629
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
630
|
+
this.ValidateResponse(response, url);
|
|
631
|
+
const body = response.Body;
|
|
632
|
+
const fields = body.fields ?? [];
|
|
633
|
+
return fields
|
|
634
|
+
.filter(f => !COMPOUND_FIELD_TYPES.has(f.type))
|
|
635
|
+
.map(f => f.name);
|
|
636
|
+
}
|
|
637
|
+
// ─── JWT Authentication ──────────────────────────────────────────
|
|
638
|
+
/**
|
|
639
|
+
* Builds a signed JWT assertion for the SF JWT Bearer Token flow.
|
|
640
|
+
*/
|
|
641
|
+
BuildJWTAssertion(config) {
|
|
642
|
+
const now = Math.floor(Date.now() / 1000);
|
|
643
|
+
const payload = {
|
|
644
|
+
iss: config.ClientId,
|
|
645
|
+
sub: config.Username,
|
|
646
|
+
aud: config.LoginUrl,
|
|
647
|
+
exp: now + 300, // 5 minutes
|
|
648
|
+
};
|
|
649
|
+
return jwt.sign(payload, config.PrivateKey, { algorithm: 'RS256' });
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Exchanges a JWT assertion for an access token at the SF token endpoint.
|
|
653
|
+
*/
|
|
654
|
+
async ExchangeJWTForToken(loginUrl, jwtAssertion) {
|
|
655
|
+
const tokenUrl = `${loginUrl}/services/oauth2/token`;
|
|
656
|
+
const body = `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${encodeURIComponent(jwtAssertion)}`;
|
|
657
|
+
const response = await fetch(tokenUrl, {
|
|
658
|
+
method: 'POST',
|
|
659
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
660
|
+
body,
|
|
661
|
+
signal: AbortSignal.timeout(this.effectiveRequestTimeoutMs),
|
|
662
|
+
});
|
|
663
|
+
if (!response.ok) {
|
|
664
|
+
const errorBody = await response.text();
|
|
665
|
+
throw new Error(`Salesforce JWT token exchange failed (HTTP ${response.status}): ${errorBody}`);
|
|
666
|
+
}
|
|
667
|
+
const result = await response.json();
|
|
668
|
+
if (!result.access_token || !result.instance_url) {
|
|
669
|
+
throw new Error('Salesforce token response missing access_token or instance_url');
|
|
670
|
+
}
|
|
671
|
+
return result;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Exchanges Client Credentials for an access token.
|
|
675
|
+
* Used with Connected Apps configured for the OAuth 2.0 Client Credentials flow.
|
|
676
|
+
*/
|
|
677
|
+
async ExchangeClientCredentialsForToken(config) {
|
|
678
|
+
if (!config.ClientSecret) {
|
|
679
|
+
throw new Error('Client Credentials flow requires clientSecret');
|
|
680
|
+
}
|
|
681
|
+
const tokenUrl = `${config.LoginUrl}/services/oauth2/token`;
|
|
682
|
+
const body = new URLSearchParams({
|
|
683
|
+
grant_type: 'client_credentials',
|
|
684
|
+
client_id: config.ClientId,
|
|
685
|
+
client_secret: config.ClientSecret,
|
|
686
|
+
}).toString();
|
|
687
|
+
const response = await fetch(tokenUrl, {
|
|
688
|
+
method: 'POST',
|
|
689
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
690
|
+
body,
|
|
691
|
+
signal: AbortSignal.timeout(this.effectiveRequestTimeoutMs),
|
|
692
|
+
});
|
|
693
|
+
if (!response.ok) {
|
|
694
|
+
const errorBody = await response.text();
|
|
695
|
+
throw new Error(`Salesforce Client Credentials token exchange failed (HTTP ${response.status}): ${errorBody}`);
|
|
696
|
+
}
|
|
697
|
+
const result = await response.json();
|
|
698
|
+
if (!result.access_token || !result.instance_url) {
|
|
699
|
+
throw new Error('Salesforce token response missing access_token or instance_url');
|
|
700
|
+
}
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Checks if the cached token is still valid (within 80% of lifetime).
|
|
705
|
+
*/
|
|
706
|
+
IsTokenValid() {
|
|
707
|
+
if (!this.cachedAuth || this.tokenObtainedAt === 0)
|
|
708
|
+
return false;
|
|
709
|
+
const elapsed = Date.now() - this.tokenObtainedAt;
|
|
710
|
+
return elapsed < TOKEN_LIFETIME_MS * TOKEN_REFRESH_THRESHOLD;
|
|
711
|
+
}
|
|
712
|
+
// ─── Credential Loading ──────────────────────────────────────────
|
|
713
|
+
/**
|
|
714
|
+
* Loads credentials from the Credential entity or Configuration JSON.
|
|
715
|
+
*/
|
|
716
|
+
async LoadCredentials(companyIntegration, contextUser) {
|
|
717
|
+
// Try Credential entity first
|
|
718
|
+
const credentialID = companyIntegration.Get('CredentialID');
|
|
719
|
+
if (credentialID) {
|
|
720
|
+
const creds = await this.LoadFromCredentialEntity(credentialID, contextUser);
|
|
721
|
+
if (creds)
|
|
722
|
+
return creds;
|
|
723
|
+
}
|
|
724
|
+
// Fallback: Configuration JSON
|
|
725
|
+
const configJson = companyIntegration.Get('Configuration');
|
|
726
|
+
if (configJson) {
|
|
727
|
+
const creds = this.ParseCredentialJson(configJson);
|
|
728
|
+
if (creds)
|
|
729
|
+
return creds;
|
|
730
|
+
}
|
|
731
|
+
throw new Error('No Salesforce credentials found. Set Configuration JSON with either ' +
|
|
732
|
+
'(a) clientId + clientSecret for Client Credentials flow, or ' +
|
|
733
|
+
'(b) clientId + username + privateKey for JWT Bearer flow.');
|
|
734
|
+
}
|
|
735
|
+
async LoadFromCredentialEntity(credentialID, contextUser) {
|
|
736
|
+
const md = new Metadata();
|
|
737
|
+
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
738
|
+
const loaded = await credential.Load(credentialID);
|
|
739
|
+
if (!loaded || !credential.Values)
|
|
740
|
+
return null;
|
|
741
|
+
return this.ParseCredentialJson(credential.Values);
|
|
742
|
+
}
|
|
743
|
+
ParseCredentialJson(json) {
|
|
744
|
+
try {
|
|
745
|
+
const parsed = JSON.parse(json);
|
|
746
|
+
const clientId = parsed['clientId'] ?? parsed['ClientId'];
|
|
747
|
+
if (!clientId)
|
|
748
|
+
return null;
|
|
749
|
+
const loginUrl = parsed['loginUrl'] ?? parsed['LoginUrl'] ?? 'https://login.salesforce.com';
|
|
750
|
+
const apiVersion = parsed['apiVersion'] ?? parsed['ApiVersion'] ?? DEFAULT_API_VERSION;
|
|
751
|
+
const tokenUrl = parsed['tokenUrl'] ?? parsed['TokenUrl'];
|
|
752
|
+
// Detect auth flow: if clientSecret is present, use client_credentials;
|
|
753
|
+
// otherwise require privateKey + username for JWT Bearer
|
|
754
|
+
const clientSecret = parsed['clientSecret'] ?? parsed['ClientSecret'];
|
|
755
|
+
const privateKey = parsed['privateKey'] ?? parsed['PrivateKey'];
|
|
756
|
+
const username = parsed['username'] ?? parsed['Username'];
|
|
757
|
+
const explicitFlow = parsed['authFlow'] ?? parsed['AuthFlow'];
|
|
758
|
+
if (explicitFlow === 'client_credentials' || (clientSecret && !privateKey)) {
|
|
759
|
+
if (!clientSecret)
|
|
760
|
+
return null;
|
|
761
|
+
return {
|
|
762
|
+
AuthFlow: 'client_credentials',
|
|
763
|
+
LoginUrl: loginUrl,
|
|
764
|
+
ClientId: clientId,
|
|
765
|
+
ClientSecret: clientSecret,
|
|
766
|
+
ApiVersion: apiVersion,
|
|
767
|
+
TokenUrl: tokenUrl,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
// JWT Bearer flow
|
|
771
|
+
if (!privateKey || !username)
|
|
772
|
+
return null;
|
|
773
|
+
return {
|
|
774
|
+
AuthFlow: 'jwt_bearer',
|
|
775
|
+
LoginUrl: loginUrl,
|
|
776
|
+
ClientId: clientId,
|
|
777
|
+
Username: username,
|
|
778
|
+
PrivateKey: privateKey,
|
|
779
|
+
ApiVersion: apiVersion,
|
|
780
|
+
TokenUrl: tokenUrl,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
BuildConnectionConfig(credentials, companyIntegration) {
|
|
788
|
+
const config = {
|
|
789
|
+
AuthFlow: credentials.AuthFlow,
|
|
790
|
+
LoginUrl: credentials.LoginUrl,
|
|
791
|
+
ClientId: credentials.ClientId,
|
|
792
|
+
ApiVersion: credentials.ApiVersion,
|
|
793
|
+
Username: credentials.Username,
|
|
794
|
+
PrivateKey: credentials.PrivateKey,
|
|
795
|
+
ClientSecret: credentials.ClientSecret,
|
|
796
|
+
TokenUrl: credentials.TokenUrl,
|
|
797
|
+
};
|
|
798
|
+
const configJson = companyIntegration.Get('Configuration');
|
|
799
|
+
if (configJson) {
|
|
800
|
+
this.ApplyConfigOverrides(config, configJson);
|
|
801
|
+
}
|
|
802
|
+
return config;
|
|
803
|
+
}
|
|
804
|
+
ApplyConfigOverrides(config, json) {
|
|
805
|
+
try {
|
|
806
|
+
const parsed = JSON.parse(json);
|
|
807
|
+
const parseOptionalInt = (key) => {
|
|
808
|
+
const v = parsed[key];
|
|
809
|
+
if (v == null)
|
|
810
|
+
return undefined;
|
|
811
|
+
const n = Number(v);
|
|
812
|
+
return isNaN(n) ? undefined : Math.floor(n);
|
|
813
|
+
};
|
|
814
|
+
config.MaxRetries = parseOptionalInt('MaxRetries');
|
|
815
|
+
config.RequestTimeoutMs = parseOptionalInt('RequestTimeoutMs');
|
|
816
|
+
config.MinRequestIntervalMs = parseOptionalInt('MinRequestIntervalMs');
|
|
817
|
+
config.DefaultBatchSize = parseOptionalInt('DefaultBatchSize');
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
// Configuration JSON may not be valid — ignore
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
// ─── Rate Limit / Governor Limit Management ──────────────────────
|
|
824
|
+
/**
|
|
825
|
+
* Throttles requests to respect minimum interval between API calls.
|
|
826
|
+
*/
|
|
827
|
+
async ThrottleIfNeeded() {
|
|
828
|
+
const minInterval = this.effectiveMinRequestIntervalMs;
|
|
829
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
830
|
+
if (elapsed < minInterval) {
|
|
831
|
+
await this.Sleep(minInterval - elapsed);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Checks governor limits and pauses if approaching the daily API limit.
|
|
836
|
+
*/
|
|
837
|
+
async CheckGovernorLimits() {
|
|
838
|
+
const { CurrentUsage, DailyLimit } = this.governorState;
|
|
839
|
+
if (DailyLimit === 0)
|
|
840
|
+
return;
|
|
841
|
+
const ratio = CurrentUsage / DailyLimit;
|
|
842
|
+
if (ratio >= GOVERNOR_PAUSE_THRESHOLD) {
|
|
843
|
+
throw new Error(`Salesforce governor limit critical: ${CurrentUsage}/${DailyLimit} API calls used ` +
|
|
844
|
+
`(${Math.round(ratio * 100)}%). Sync paused to prevent REQUEST_LIMIT_EXCEEDED.`);
|
|
845
|
+
}
|
|
846
|
+
if (ratio >= GOVERNOR_THROTTLE_THRESHOLD) {
|
|
847
|
+
console.warn(`[Salesforce] Governor limit warning: ${CurrentUsage}/${DailyLimit} ` +
|
|
848
|
+
`(${Math.round(ratio * 100)}%). Adding throttle delay.`);
|
|
849
|
+
await this.Sleep(500); // Extra delay when approaching limit
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Parses the Sforce-Limit-Info header from every SF response.
|
|
854
|
+
*/
|
|
855
|
+
ParseGovernorLimits(headers) {
|
|
856
|
+
const limitInfo = headers.get('sforce-limit-info');
|
|
857
|
+
if (!limitInfo)
|
|
858
|
+
return;
|
|
859
|
+
// Format: "api-usage=25/15000"
|
|
860
|
+
const match = limitInfo.match(/api-usage=(\d+)\/(\d+)/);
|
|
861
|
+
if (match) {
|
|
862
|
+
this.governorState = {
|
|
863
|
+
CurrentUsage: parseInt(match[1], 10),
|
|
864
|
+
DailyLimit: parseInt(match[2], 10),
|
|
865
|
+
LastUpdated: Date.now(),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// ─── HTTP Helpers ────────────────────────────────────────────────
|
|
870
|
+
async FetchWithTimeout(url, method, headers, body) {
|
|
871
|
+
const fetchOptions = {
|
|
872
|
+
method,
|
|
873
|
+
headers,
|
|
874
|
+
signal: AbortSignal.timeout(this.effectiveRequestTimeoutMs),
|
|
875
|
+
};
|
|
876
|
+
if (body !== undefined && method !== 'GET' && method !== 'DELETE') {
|
|
877
|
+
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
878
|
+
}
|
|
879
|
+
return fetch(url, fetchOptions);
|
|
880
|
+
}
|
|
881
|
+
BuildRESTResponse(response, body) {
|
|
882
|
+
return {
|
|
883
|
+
Status: response.status,
|
|
884
|
+
Body: body,
|
|
885
|
+
Headers: this.ExtractHeaders(response),
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
ExtractHeaders(response) {
|
|
889
|
+
const headers = {};
|
|
890
|
+
response.headers.forEach((value, key) => {
|
|
891
|
+
headers[key.toLowerCase()] = value;
|
|
892
|
+
});
|
|
893
|
+
return headers;
|
|
894
|
+
}
|
|
895
|
+
async SafeParseJSON(response) {
|
|
896
|
+
try {
|
|
897
|
+
return await response.json();
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
return {};
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
CalculateRetryDelay(attempt) {
|
|
904
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)
|
|
905
|
+
return Math.min(1000 * Math.pow(2, attempt), 30000);
|
|
906
|
+
}
|
|
907
|
+
Sleep(ms) {
|
|
908
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
909
|
+
}
|
|
910
|
+
// ─── SF Type Mapping ─────────────────────────────────────────────
|
|
911
|
+
/**
|
|
912
|
+
* Maps a Salesforce field describe to an ExternalFieldSchema.
|
|
913
|
+
*/
|
|
914
|
+
MapSFFieldToSchema(f) {
|
|
915
|
+
return {
|
|
916
|
+
Name: f.name,
|
|
917
|
+
Label: f.label,
|
|
918
|
+
Description: f.inlineHelpText ?? undefined,
|
|
919
|
+
DataType: this.MapSalesforceType(f.type),
|
|
920
|
+
IsRequired: !f.nillable && !f.defaultedOnCreate,
|
|
921
|
+
IsUniqueKey: f.externalId || f.name === 'Id',
|
|
922
|
+
IsReadOnly: f.calculated || !f.updateable || SYSTEM_READ_ONLY_FIELDS.has(f.name),
|
|
923
|
+
IsForeignKey: f.type === 'reference' && f.referenceTo.length > 0,
|
|
924
|
+
ForeignKeyTarget: f.referenceTo.length > 0 ? f.referenceTo[0] : null,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Maps a Salesforce field type to a generic integration type.
|
|
929
|
+
*/
|
|
930
|
+
MapSalesforceType(sfType) {
|
|
931
|
+
switch (sfType) {
|
|
932
|
+
case 'string':
|
|
933
|
+
case 'textarea':
|
|
934
|
+
case 'url':
|
|
935
|
+
case 'email':
|
|
936
|
+
case 'phone':
|
|
937
|
+
case 'picklist':
|
|
938
|
+
case 'multipicklist':
|
|
939
|
+
case 'combobox':
|
|
940
|
+
return 'string';
|
|
941
|
+
case 'int':
|
|
942
|
+
return 'integer';
|
|
943
|
+
case 'double':
|
|
944
|
+
case 'currency':
|
|
945
|
+
case 'percent':
|
|
946
|
+
return 'decimal';
|
|
947
|
+
case 'boolean':
|
|
948
|
+
return 'boolean';
|
|
949
|
+
case 'date':
|
|
950
|
+
return 'date';
|
|
951
|
+
case 'datetime':
|
|
952
|
+
return 'datetime';
|
|
953
|
+
case 'time':
|
|
954
|
+
return 'time';
|
|
955
|
+
case 'id':
|
|
956
|
+
case 'reference':
|
|
957
|
+
return 'nvarchar(18)';
|
|
958
|
+
case 'base64':
|
|
959
|
+
case 'encryptedstring':
|
|
960
|
+
return 'text';
|
|
43
961
|
default:
|
|
44
|
-
return
|
|
962
|
+
return 'string';
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// ─── SOQL Helpers ────────────────────────────────────────────────
|
|
966
|
+
/**
|
|
967
|
+
* Formats a watermark value as a SOQL datetime literal.
|
|
968
|
+
*/
|
|
969
|
+
FormatSOQLDateTime(value) {
|
|
970
|
+
// If it's already in ISO 8601 format, it works directly in SOQL
|
|
971
|
+
// SOQL accepts: 2026-03-13T10:30:00.000Z or 2026-03-13T10:30:00.000+00:00
|
|
972
|
+
if (value.includes('T'))
|
|
973
|
+
return value;
|
|
974
|
+
// If it's just a date, append time
|
|
975
|
+
return `${value}T00:00:00.000Z`;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Builds a SOQL WHERE clause from a Filters map.
|
|
979
|
+
*/
|
|
980
|
+
BuildWhereClauseFromFilters(filters) {
|
|
981
|
+
const conditions = Object.entries(filters).map(([field, value]) => {
|
|
982
|
+
const escaped = value.replace(/'/g, "\\'");
|
|
983
|
+
return `${field} = '${escaped}'`;
|
|
984
|
+
});
|
|
985
|
+
return conditions.length > 0 ? conditions.join(' AND ') : null;
|
|
986
|
+
}
|
|
987
|
+
// ─── Error Handling ──────────────────────────────────────────────
|
|
988
|
+
/**
|
|
989
|
+
* Checks if a response contains a specific SF error code.
|
|
990
|
+
*/
|
|
991
|
+
HasSFErrorCode(response, errorCode) {
|
|
992
|
+
const errors = this.ExtractSFErrors(response);
|
|
993
|
+
return errors.some(e => e.errorCode === errorCode);
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Extracts SF error objects from a response.
|
|
997
|
+
*/
|
|
998
|
+
ExtractSFErrors(response) {
|
|
999
|
+
const body = response.Body;
|
|
1000
|
+
if (Array.isArray(body)) {
|
|
1001
|
+
return body;
|
|
1002
|
+
}
|
|
1003
|
+
if (body && typeof body === 'object' && 'message' in body) {
|
|
1004
|
+
return [body];
|
|
1005
|
+
}
|
|
1006
|
+
return [];
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Checks if a response body contains an UNABLE_TO_LOCK_ROW error.
|
|
1010
|
+
*/
|
|
1011
|
+
IsLockRowError(body) {
|
|
1012
|
+
if (!Array.isArray(body))
|
|
1013
|
+
return false;
|
|
1014
|
+
return body.some((e) => e['errorCode'] === 'UNABLE_TO_LOCK_ROW');
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Builds a CRUDResult for error responses.
|
|
1018
|
+
*/
|
|
1019
|
+
BuildCRUDError(response, operation, objectName) {
|
|
1020
|
+
const errors = this.ExtractSFErrors(response);
|
|
1021
|
+
const message = errors.length > 0
|
|
1022
|
+
? errors.map(e => `${e.errorCode}: ${e.message}`).join('; ')
|
|
1023
|
+
: `[Salesforce] ${operation} on ${objectName} failed (HTTP ${response.Status})`;
|
|
1024
|
+
return {
|
|
1025
|
+
Success: false,
|
|
1026
|
+
ErrorMessage: message,
|
|
1027
|
+
StatusCode: response.Status,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Handles write errors with special retry logic for UNABLE_TO_LOCK_ROW.
|
|
1032
|
+
*/
|
|
1033
|
+
async HandleWriteError(response, operation, objectName, externalID, auth, headers, url, body) {
|
|
1034
|
+
// UNABLE_TO_LOCK_ROW — retry once more
|
|
1035
|
+
if (this.HasSFErrorCode(response, 'UNABLE_TO_LOCK_ROW')) {
|
|
1036
|
+
console.warn(`[Salesforce] Row lock contention on ${objectName}/${externalID}, retrying...`);
|
|
1037
|
+
await this.Sleep(1000);
|
|
1038
|
+
const retry = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, body);
|
|
1039
|
+
if (retry.Status === 204 || (retry.Status >= 200 && retry.Status < 300)) {
|
|
1040
|
+
return { Success: true, ExternalID: externalID, StatusCode: retry.Status };
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return this.BuildCRUDError(response, operation, objectName);
|
|
1044
|
+
}
|
|
1045
|
+
// ─── Record Conversion ───────────────────────────────────────────
|
|
1046
|
+
/**
|
|
1047
|
+
* Converts a raw SF API record to an ExternalRecord.
|
|
1048
|
+
* Strips the SF `attributes` metadata object from the fields.
|
|
1049
|
+
*/
|
|
1050
|
+
RawToExternalRecord(raw, objectType) {
|
|
1051
|
+
const fields = {};
|
|
1052
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
1053
|
+
if (key === 'attributes')
|
|
1054
|
+
continue; // SF metadata, not a data field
|
|
1055
|
+
fields[key] = value;
|
|
45
1056
|
}
|
|
1057
|
+
return {
|
|
1058
|
+
ExternalID: String(raw['Id'] ?? ''),
|
|
1059
|
+
ObjectType: objectType,
|
|
1060
|
+
Fields: fields,
|
|
1061
|
+
ModifiedAt: raw['SystemModstamp']
|
|
1062
|
+
? new Date(raw['SystemModstamp'])
|
|
1063
|
+
: undefined,
|
|
1064
|
+
};
|
|
46
1065
|
}
|
|
47
|
-
/**
|
|
48
|
-
|
|
1066
|
+
/**
|
|
1067
|
+
* Strips read-only and system fields from an attributes map before write operations.
|
|
1068
|
+
*/
|
|
1069
|
+
StripReadOnlyFields(attributes) {
|
|
1070
|
+
const result = {};
|
|
1071
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
1072
|
+
if (SYSTEM_READ_ONLY_FIELDS.has(key))
|
|
1073
|
+
continue;
|
|
1074
|
+
if (value === undefined)
|
|
1075
|
+
continue;
|
|
1076
|
+
result[key] = value;
|
|
1077
|
+
}
|
|
1078
|
+
return result;
|
|
1079
|
+
}
|
|
1080
|
+
// ─── Schema Building ─────────────────────────────────────────────
|
|
1081
|
+
async BuildSourceObjectFromDescribe(companyIntegration, contextUser, objectName) {
|
|
1082
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1083
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${objectName}/describe`;
|
|
1084
|
+
const headers = this.BuildHeaders(auth);
|
|
1085
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1086
|
+
this.ValidateResponse(response, url);
|
|
1087
|
+
const body = response.Body;
|
|
1088
|
+
const sfFields = (body.fields ?? []).filter(f => !COMPOUND_FIELD_TYPES.has(f.type));
|
|
1089
|
+
const fields = sfFields.map(f => ({
|
|
1090
|
+
Name: f.name,
|
|
1091
|
+
Label: f.label,
|
|
1092
|
+
Description: f.inlineHelpText ?? undefined,
|
|
1093
|
+
SourceType: this.MapSalesforceType(f.type),
|
|
1094
|
+
IsRequired: !f.nillable && !f.defaultedOnCreate,
|
|
1095
|
+
MaxLength: f.length > 0 ? f.length : null,
|
|
1096
|
+
Precision: f.precision > 0 ? f.precision : null,
|
|
1097
|
+
Scale: f.scale > 0 ? f.scale : null,
|
|
1098
|
+
DefaultValue: f.defaultValue != null ? String(f.defaultValue) : null,
|
|
1099
|
+
IsPrimaryKey: f.name === 'Id',
|
|
1100
|
+
IsForeignKey: f.type === 'reference' && f.referenceTo.length > 0,
|
|
1101
|
+
ForeignKeyTarget: f.referenceTo.length > 0 ? f.referenceTo[0] : null,
|
|
1102
|
+
}));
|
|
1103
|
+
const relationships = sfFields
|
|
1104
|
+
.filter(f => f.type === 'reference' && f.referenceTo.length > 0)
|
|
1105
|
+
.map(f => ({
|
|
1106
|
+
FieldName: f.name,
|
|
1107
|
+
TargetObject: f.referenceTo[0],
|
|
1108
|
+
TargetField: 'Id',
|
|
1109
|
+
}));
|
|
1110
|
+
return {
|
|
1111
|
+
ExternalName: objectName,
|
|
1112
|
+
ExternalLabel: body.label ?? objectName,
|
|
1113
|
+
Description: undefined,
|
|
1114
|
+
Fields: fields,
|
|
1115
|
+
PrimaryKeyFields: ['Id'],
|
|
1116
|
+
Relationships: relationships,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
// ─── Default Field Mappings ──────────────────────────────────────
|
|
1120
|
+
GetContactMappings() {
|
|
49
1121
|
return [
|
|
50
1122
|
{ SourceFieldName: 'Email', DestinationFieldName: 'Email', IsKeyField: true },
|
|
51
1123
|
{ SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
|
|
52
1124
|
{ SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
|
|
53
1125
|
{ SourceFieldName: 'Phone', DestinationFieldName: 'Phone' },
|
|
1126
|
+
{ SourceFieldName: 'MobilePhone', DestinationFieldName: 'MobilePhone' },
|
|
54
1127
|
{ SourceFieldName: 'Title', DestinationFieldName: 'Title' },
|
|
55
1128
|
{ SourceFieldName: 'Department', DestinationFieldName: 'Department' },
|
|
1129
|
+
{ SourceFieldName: 'MailingStreet', DestinationFieldName: 'Address1' },
|
|
1130
|
+
{ SourceFieldName: 'MailingCity', DestinationFieldName: 'City' },
|
|
1131
|
+
{ SourceFieldName: 'MailingState', DestinationFieldName: 'StateOrProvince' },
|
|
1132
|
+
{ SourceFieldName: 'MailingPostalCode', DestinationFieldName: 'PostalCode' },
|
|
1133
|
+
{ SourceFieldName: 'MailingCountry', DestinationFieldName: 'Country' },
|
|
56
1134
|
];
|
|
57
1135
|
}
|
|
58
|
-
|
|
59
|
-
getAccountMappings() {
|
|
1136
|
+
GetAccountMappings() {
|
|
60
1137
|
return [
|
|
61
1138
|
{ SourceFieldName: 'Name', DestinationFieldName: 'Name', IsKeyField: true },
|
|
62
|
-
{ SourceFieldName: '
|
|
1139
|
+
{ SourceFieldName: 'BillingStreet', DestinationFieldName: 'Address1' },
|
|
63
1140
|
{ SourceFieldName: 'BillingCity', DestinationFieldName: 'City' },
|
|
64
|
-
{ SourceFieldName: 'BillingState', DestinationFieldName: '
|
|
1141
|
+
{ SourceFieldName: 'BillingState', DestinationFieldName: 'StateOrProvince' },
|
|
1142
|
+
{ SourceFieldName: 'BillingPostalCode', DestinationFieldName: 'PostalCode' },
|
|
1143
|
+
{ SourceFieldName: 'BillingCountry', DestinationFieldName: 'Country' },
|
|
65
1144
|
{ SourceFieldName: 'Phone', DestinationFieldName: 'Phone' },
|
|
1145
|
+
{ SourceFieldName: 'Website', DestinationFieldName: 'WebSite' },
|
|
1146
|
+
{ SourceFieldName: 'Industry', DestinationFieldName: 'Industry' },
|
|
1147
|
+
{ SourceFieldName: 'Description', DestinationFieldName: 'Description' },
|
|
66
1148
|
];
|
|
67
1149
|
}
|
|
1150
|
+
GetLeadMappings() {
|
|
1151
|
+
return [
|
|
1152
|
+
{ SourceFieldName: 'Email', DestinationFieldName: 'Email', IsKeyField: true },
|
|
1153
|
+
{ SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
|
|
1154
|
+
{ SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
|
|
1155
|
+
{ SourceFieldName: 'Phone', DestinationFieldName: 'Phone' },
|
|
1156
|
+
{ SourceFieldName: 'Company', DestinationFieldName: 'CompanyName' },
|
|
1157
|
+
{ SourceFieldName: 'Title', DestinationFieldName: 'Title' },
|
|
1158
|
+
];
|
|
1159
|
+
}
|
|
1160
|
+
// ─── Validation Helpers ──────────────────────────────────────────
|
|
1161
|
+
ValidateResponse(response, url) {
|
|
1162
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
1163
|
+
const preview = this.PreviewBody(response.Body);
|
|
1164
|
+
throw new Error(`HTTP ${response.Status} from ${url}: ${preview}`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
PreviewBody(body) {
|
|
1168
|
+
if (typeof body === 'string')
|
|
1169
|
+
return body.slice(0, 500);
|
|
1170
|
+
return JSON.stringify(body).slice(0, 500);
|
|
1171
|
+
}
|
|
68
1172
|
};
|
|
69
1173
|
SalesforceConnector = __decorate([
|
|
70
1174
|
RegisterClass(BaseIntegrationConnector, 'SalesforceConnector')
|