@memberjunction/integration-connectors 5.40.2 → 5.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CventConnector.d.ts +146 -0
- package/dist/CventConnector.d.ts.map +1 -0
- package/dist/CventConnector.js +540 -0
- package/dist/CventConnector.js.map +1 -0
- package/dist/DynamicsDataverseConnector.d.ts +296 -0
- package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
- package/dist/DynamicsDataverseConnector.js +790 -0
- package/dist/DynamicsDataverseConnector.js.map +1 -0
- package/dist/FontevaConnector.d.ts +153 -0
- package/dist/FontevaConnector.d.ts.map +1 -0
- package/dist/FontevaConnector.js +421 -0
- package/dist/FontevaConnector.js.map +1 -0
- package/dist/GrowthZoneConnector.d.ts +123 -125
- package/dist/GrowthZoneConnector.d.ts.map +1 -1
- package/dist/GrowthZoneConnector.js +456 -499
- package/dist/GrowthZoneConnector.js.map +1 -1
- package/dist/HivebriteConnector.d.ts +151 -0
- package/dist/HivebriteConnector.d.ts.map +1 -0
- package/dist/HivebriteConnector.js +533 -0
- package/dist/HivebriteConnector.js.map +1 -0
- package/dist/IMISConnector.d.ts +112 -96
- package/dist/IMISConnector.d.ts.map +1 -1
- package/dist/IMISConnector.js +430 -417
- package/dist/IMISConnector.js.map +1 -1
- package/dist/MemberSuiteConnector.d.ts +185 -0
- package/dist/MemberSuiteConnector.d.ts.map +1 -0
- package/dist/MemberSuiteConnector.js +762 -0
- package/dist/MemberSuiteConnector.js.map +1 -0
- package/dist/NeonCRMConnector.d.ts +273 -0
- package/dist/NeonCRMConnector.d.ts.map +1 -0
- package/dist/NeonCRMConnector.js +1039 -0
- package/dist/NeonCRMConnector.js.map +1 -0
- package/dist/NetForumConnector.d.ts +140 -47
- package/dist/NetForumConnector.d.ts.map +1 -1
- package/dist/NetForumConnector.js +668 -395
- package/dist/NetForumConnector.js.map +1 -1
- package/dist/NetSuiteConnector.d.ts +116 -50
- package/dist/NetSuiteConnector.d.ts.map +1 -1
- package/dist/NetSuiteConnector.js +608 -510
- package/dist/NetSuiteConnector.js.map +1 -1
- package/dist/NimbleAMSConnector.d.ts +104 -11
- package/dist/NimbleAMSConnector.d.ts.map +1 -1
- package/dist/NimbleAMSConnector.js +552 -258
- package/dist/NimbleAMSConnector.js.map +1 -1
- package/dist/NoviConnector.d.ts +178 -0
- package/dist/NoviConnector.d.ts.map +1 -0
- package/dist/NoviConnector.js +566 -0
- package/dist/NoviConnector.js.map +1 -0
- package/dist/ORCIDConnector.d.ts +207 -0
- package/dist/ORCIDConnector.d.ts.map +1 -0
- package/dist/ORCIDConnector.js +708 -0
- package/dist/ORCIDConnector.js.map +1 -0
- package/dist/OpenWaterConnector.d.ts +173 -0
- package/dist/OpenWaterConnector.d.ts.map +1 -0
- package/dist/OpenWaterConnector.js +893 -0
- package/dist/OpenWaterConnector.js.map +1 -0
- package/dist/PathLMSConnector.d.ts +395 -0
- package/dist/PathLMSConnector.d.ts.map +1 -0
- package/dist/PathLMSConnector.js +1281 -0
- package/dist/PathLMSConnector.js.map +1 -0
- package/dist/PheedLoopConnector.d.ts +119 -0
- package/dist/PheedLoopConnector.d.ts.map +1 -0
- package/dist/PheedLoopConnector.js +429 -0
- package/dist/PheedLoopConnector.js.map +1 -0
- package/dist/PropFuelConnector.d.ts +135 -61
- package/dist/PropFuelConnector.d.ts.map +1 -1
- package/dist/PropFuelConnector.js +366 -467
- package/dist/PropFuelConnector.js.map +1 -1
- package/dist/RhythmConnector.d.ts +188 -0
- package/dist/RhythmConnector.d.ts.map +1 -0
- package/dist/RhythmConnector.js +784 -0
- package/dist/RhythmConnector.js.map +1 -0
- package/dist/SalesforceConnector.d.ts +69 -3
- package/dist/SalesforceConnector.d.ts.map +1 -1
- package/dist/SalesforceConnector.js +188 -166
- package/dist/SalesforceConnector.js.map +1 -1
- package/dist/SharePointConnector.d.ts +37 -30
- package/dist/SharePointConnector.d.ts.map +1 -1
- package/dist/SharePointConnector.js +163 -243
- package/dist/SharePointConnector.js.map +1 -1
- package/dist/YourMembershipConnector.d.ts.map +1 -1
- package/dist/YourMembershipConnector.js +13 -5
- package/dist/YourMembershipConnector.js.map +1 -1
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/dist/BettyConnector.d.ts +0 -288
- package/dist/BettyConnector.d.ts.map +0 -1
- package/dist/BettyConnector.js +0 -909
- package/dist/BettyConnector.js.map +0 -1
|
@@ -5,601 +5,699 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
/**
|
|
8
|
-
* NetSuiteConnector — Integration connector for Oracle NetSuite
|
|
8
|
+
* NetSuiteConnector — Integration connector for Oracle NetSuite (SuiteTalk REST Web Services).
|
|
9
9
|
*
|
|
10
|
-
* API
|
|
10
|
+
* API docs: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/ (SuiteTalk REST API Guide)
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Authorization header with HMAC-SHA256 signature per OAuth 1.0 spec.
|
|
15
|
-
* Base URL: https://{accountId}.suitetalk.api.netsuite.com/services/rest/record/v1
|
|
16
|
-
* SuiteQL: https://{accountId}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql
|
|
17
|
-
* Pagination: limit/offset (max 1000 per page)
|
|
18
|
-
* Rate limits: 15 concurrent requests/account
|
|
19
|
-
* Incremental: SuiteQL queries with lastmodifieddate >= watermark
|
|
20
|
-
* CRUD: Full CRUD via REST Record API on ALL record types
|
|
12
|
+
* SCOPE: SuiteTalk REST Web Services only — the Record Service (CRUD on standard + custom record
|
|
13
|
+
* types) + the SuiteQL query service. SOAP / RESTlets / SuiteAnalytics Connect are out of scope.
|
|
21
14
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
15
|
+
* ── Auth (selected per-connection by Configuration / credential) ───────────────────────────────
|
|
16
|
+
* • OAuth 1.0a TBA (Token-Based Authentication) — the primary documented flow. Long-lived,
|
|
17
|
+
* non-expiring ConsumerKey/ConsumerSecret + TokenID/TokenSecret, signed per request with
|
|
18
|
+
* HMAC-SHA256 and a per-account `realm`. Signing is delegated to the shared `OAuth1aSigner`
|
|
19
|
+
* auth-helper — NEVER inline crypto.
|
|
20
|
+
* • OAuth 2.0 bearer — the documented alternate (Authorization Code flow). A pre-minted bearer
|
|
21
|
+
* access token is supplied via the credential/Configuration and sent as `Authorization: Bearer`.
|
|
22
|
+
* (Refresh, when a refresh token + token URL are present, is delegated to OAuth2TokenManager.)
|
|
23
|
+
* Mode is chosen by Configuration.AuthFlow ('oauth1-tba' | 'oauth2') or inferred from the
|
|
24
|
+
* credential keys present (BearerToken/AccessToken ⇒ OAuth2; ConsumerKey+TokenID ⇒ TBA).
|
|
26
25
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
26
|
+
* ── Host (tenant-agnostic — NO baked account) ──────────────────────────────────────────────────
|
|
27
|
+
* The base URL is built at runtime from Configuration.AccountID:
|
|
28
|
+
* `https://{accountId}.suitetalk.api.netsuite.com` (e.g. '1234567' prod, '1234567_SB1' sandbox).
|
|
29
|
+
* Per-account subdomains require the account id be lowercased and '_' → '-' in the subdomain.
|
|
30
|
+
*
|
|
31
|
+
* ── Reads ──────────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
* FetchChanges runs SuiteQL: POST /services/rest/query/v1/suiteql with the SQL in the body
|
|
33
|
+
* `{ q: '<SQL>' }` and the REQUIRED header `Prefer: transient`. This expresses the lastModifiedDate
|
|
34
|
+
* incremental watermark predicate (a thing the generic GET path cannot), so FetchChanges is the one
|
|
35
|
+
* genuinely-idiosyncratic override. Everything else rides the base.
|
|
36
|
+
*
|
|
37
|
+
* ── CRUD ───────────────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
* Create/Update/Delete/Get use the base class's GENERIC per-operation path — they read the IO's
|
|
39
|
+
* Create/Update/Delete APIPath/Method/BodyShape/BodyKey/IDLocation columns and dispatch. NetSuite's
|
|
40
|
+
* Record Service CRUD is a plain REST shape (POST collection, PATCH /{id}, DELETE /{id}), so no
|
|
41
|
+
* override is needed or written.
|
|
42
|
+
*
|
|
43
|
+
* ── Pagination ─────────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
* Offset/limit (default 1000). Responses carry the HATEOAS envelope { items, count, totalResults,
|
|
45
|
+
* hasMore, offset, links:[{rel:'next',href}] } — surfaced in NormalizeResponse + ExtractPaginationInfo.
|
|
46
|
+
*
|
|
47
|
+
* ── Incremental ────────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
* lastModifiedDate watermark (via WatermarkService in the engine; the connector applies the IO's
|
|
49
|
+
* IncrementalWatermarkField inside the SuiteQL predicate and returns the max-seen on full-batch success).
|
|
50
|
+
*
|
|
51
|
+
* ── Discovery ──────────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
* Rides the metadata-catalog MECHANISM (no baked catalog): GET /services/rest/record/v1/metadata-catalog
|
|
53
|
+
* enumerates the record types accessible to the credential; per-type field schema is fetched from the
|
|
54
|
+
* same catalog (Accept: application/schema+json). DiscoverObjects UNIONS the Declared baseline (the 200+
|
|
55
|
+
* standard record types + the sublist-access-path children, which the catalog never lists) with the live
|
|
56
|
+
* catalog's record types — catalog slugs are name-aligned back to the Declared names via each IO's
|
|
57
|
+
* Configuration.recordTypeSlug so a slug never duplicates an existing Declared object; unknown slugs pass
|
|
58
|
+
* through as new customs. Discovery is NON-authoritative (DiscoveryIsAuthoritative=false): because the
|
|
59
|
+
* catalog is a partial enumeration (no sublist children, slug-vs-humanized names), absence proves nothing
|
|
60
|
+
* and nothing is ever deactivated. Standard baseline lives in the Declared metadata file; a credential
|
|
61
|
+
* only ADDS the account's custom records on top.
|
|
34
62
|
*/
|
|
35
|
-
import { createHmac, randomBytes } from 'crypto';
|
|
36
63
|
import { RegisterClass } from '@memberjunction/global';
|
|
37
64
|
import { Metadata } from '@memberjunction/core';
|
|
38
|
-
import {
|
|
65
|
+
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
66
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, OAuth1aSigner, OAuth2TokenManager, } from '@memberjunction/integration-engine';
|
|
67
|
+
import { z } from 'zod';
|
|
39
68
|
// ─── Constants ────────────────────────────────────────────────────────────
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
const NS_DEFAULT_PAGE_SIZE = 1000;
|
|
70
|
+
const NS_MAX_RETRIES = 3;
|
|
71
|
+
const NS_REQUEST_TIMEOUT_MS = 90_000;
|
|
72
|
+
const NS_RECORD_BASE_PATH = '/services/rest/record/v1';
|
|
73
|
+
const NS_SUITEQL_PATH = '/services/rest/query/v1/suiteql';
|
|
74
|
+
const NS_METADATA_CATALOG_PATH = '/services/rest/record/v1/metadata-catalog';
|
|
75
|
+
const NS_SERVERTIME_PATH = '/services/rest/system/v1/serverTime';
|
|
76
|
+
// NetSuite REST media type for record bodies (per docs: application/vnd.oracle.resource+json).
|
|
77
|
+
const NS_RECORD_MEDIA_TYPE = 'application/vnd.oracle.resource+json';
|
|
78
|
+
// Concurrency-governed API (no RPS); the smallest documented tier is 5 concurrent. Stay well under it.
|
|
79
|
+
const NS_MAX_CONCURRENCY = 4;
|
|
80
|
+
// ─── Config (typed via Zod) ─────────────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* The per-connection config the connector reads from the credential JSON or
|
|
83
|
+
* CompanyIntegration.Configuration. AccountID is always required; the auth fields
|
|
84
|
+
* present select the auth mode.
|
|
85
|
+
*/
|
|
86
|
+
const NetSuiteConfigSchema = z.object({
|
|
87
|
+
/** Per-account id, e.g. '1234567' or '1234567_SB1'. Builds the host subdomain. */
|
|
88
|
+
AccountID: z.string().min(1),
|
|
89
|
+
/**
|
|
90
|
+
* Optional explicit host-root override (no trailing slash). When set, it is used verbatim
|
|
91
|
+
* instead of deriving `https://{accountId}.suitetalk.api.netsuite.com` from AccountID.
|
|
92
|
+
* Production normally leaves this UNSET (host derives from AccountID). It exists for a custom
|
|
93
|
+
* gateway/proxy and for pointing the connector at a mock vendor in credential-free e2e tests —
|
|
94
|
+
* the same `BaseURL`-from-config hook iMIS/Hivebrite/GrowthZone expose.
|
|
95
|
+
*/
|
|
96
|
+
HostBaseURL: z.string().url().optional(),
|
|
97
|
+
/** Explicit auth mode; inferred when absent. */
|
|
98
|
+
AuthFlow: z.enum(['oauth1-tba', 'oauth2']).optional(),
|
|
99
|
+
// OAuth 1.0a TBA
|
|
100
|
+
ConsumerKey: z.string().optional(),
|
|
101
|
+
ConsumerSecret: z.string().optional(),
|
|
102
|
+
TokenID: z.string().optional(),
|
|
103
|
+
TokenSecret: z.string().optional(),
|
|
104
|
+
// OAuth 2.0 bearer
|
|
105
|
+
BearerToken: z.string().optional(),
|
|
106
|
+
AccessToken: z.string().optional(),
|
|
107
|
+
RefreshToken: z.string().optional(),
|
|
108
|
+
ClientID: z.string().optional(),
|
|
109
|
+
ClientSecret: z.string().optional(),
|
|
110
|
+
TokenURL: z.string().optional(),
|
|
111
|
+
});
|
|
57
112
|
// ─── Connector ────────────────────────────────────────────────────────────
|
|
58
113
|
let NetSuiteConnector = class NetSuiteConnector extends BaseRESTIntegrationConnector {
|
|
59
114
|
constructor() {
|
|
60
115
|
super(...arguments);
|
|
61
|
-
this.
|
|
62
|
-
/** Cache of discovered record types: lowercase name → { name, href } */
|
|
63
|
-
this.recordTypeCache = new Map();
|
|
116
|
+
this.oauth2Manager = new OAuth2TokenManager();
|
|
64
117
|
}
|
|
118
|
+
// ── Identity + capabilities ──────────────────────────────────────────
|
|
65
119
|
get IntegrationName() { return 'NetSuite'; }
|
|
66
120
|
get SupportsCreate() { return true; }
|
|
67
121
|
get SupportsUpdate() { return true; }
|
|
68
122
|
get SupportsDelete() { return true; }
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
123
|
+
/**
|
|
124
|
+
* NON-authoritative for deactivation (deliberately false — the T3-deadlock-safe posture).
|
|
125
|
+
*
|
|
126
|
+
* The metadata-catalog enumerates the Record-Service record types (standard + custom) — but it is
|
|
127
|
+
* NOT the complete gamut of this connector's emitted IOs: the contract retains sublist-access-path
|
|
128
|
+
* children (InvoiceItem, SalesOrderItem) that are reachable as nested graph children and NEVER
|
|
129
|
+
* appear in the record-type catalog (SublistScopeExtension). It also returns lowercase record-type
|
|
130
|
+
* SLUGS (`phonecall`) while Declared standard objects are humanized (`Phone Call`). An authoritative
|
|
131
|
+
* (deactivating) refresh would therefore wrongly Disable every Declared sublist child and any object
|
|
132
|
+
* whose catalog slug doesn't byte-match the Declared name. Per the convention rule — a partial/scoped
|
|
133
|
+
* enumeration is NEVER authoritative — absence here proves nothing, so we deactivate nothing. The
|
|
134
|
+
* standard universe is the Declared metadata baseline; a credential only ADDS the account's customs.
|
|
135
|
+
*/
|
|
136
|
+
get DiscoveryIsAuthoritative() { return false; }
|
|
137
|
+
/**
|
|
138
|
+
* NetSuite governs by CONCURRENT-request limit (5–50 by tier), not requests/sec. We cap inner
|
|
139
|
+
* concurrency conservatively below the smallest tier; the engine's adaptive controller ramps within it.
|
|
140
|
+
*/
|
|
141
|
+
get MaxConcurrencyHint() { return NS_MAX_CONCURRENCY; }
|
|
142
|
+
/** No documented RPS policy — concurrency-governed. Return null so the engine derives a conservative rate. */
|
|
143
|
+
get RateLimitPolicy() { return null; }
|
|
144
|
+
/**
|
|
145
|
+
* SuiteQL fetches in `ORDER BY lastModifiedDate` and an updated record always re-surfaces at a new,
|
|
146
|
+
* higher modstamp — so the last batch's max IS the true high-water mark. Safe for the engine to use
|
|
147
|
+
* the returned watermark to narrow the next incremental.
|
|
148
|
+
*/
|
|
149
|
+
get MonotonicWatermark() { return true; }
|
|
150
|
+
/**
|
|
151
|
+
* For watermark-less objects, NetSuite's universal numeric `id` is a stable monotonic ordering key
|
|
152
|
+
* usable for keyset/seek resume.
|
|
153
|
+
*/
|
|
154
|
+
StableOrderingKey(_objectName) { return 'id'; }
|
|
155
|
+
/** Parse NetSuite's 429 Retry-After (seconds) into ms; concurrency limit errors carry no precise hint. */
|
|
156
|
+
ExtractRetryAfterMs(error) {
|
|
157
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
158
|
+
const m = msg.match(/retry-after[:\s]+(\d+)/i);
|
|
159
|
+
if (m)
|
|
160
|
+
return parseInt(m[1], 10) * 1000;
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
// ── Config / credential parsing ──────────────────────────────────────
|
|
164
|
+
async ParseConfig(companyIntegration, contextUser, provider) {
|
|
165
|
+
// Credential JSON takes precedence over the non-secret Configuration JSON.
|
|
166
|
+
if (companyIntegration.CredentialID) {
|
|
167
|
+
const md = provider ?? new Metadata();
|
|
168
|
+
const cred = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
169
|
+
const loaded = await cred.Load(companyIntegration.CredentialID);
|
|
170
|
+
if (loaded && cred.Values) {
|
|
171
|
+
return this.ValidateConfig(JSON.parse(cred.Values));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (companyIntegration.Configuration) {
|
|
175
|
+
return this.ValidateConfig(JSON.parse(companyIntegration.Configuration));
|
|
176
|
+
}
|
|
177
|
+
throw new Error('NetSuite connector requires a CredentialID or Configuration JSON carrying AccountID + auth credentials.');
|
|
178
|
+
}
|
|
179
|
+
ValidateConfig(raw) {
|
|
180
|
+
const parsed = NetSuiteConfigSchema.safeParse(raw);
|
|
181
|
+
if (!parsed.success) {
|
|
182
|
+
throw new Error(`NetSuite Configuration is invalid: ${parsed.error.issues.map(i => i.message).join('; ')}`);
|
|
83
183
|
}
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
return
|
|
90
|
-
config.
|
|
91
|
-
|
|
92
|
-
config.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
184
|
+
return parsed.data;
|
|
185
|
+
}
|
|
186
|
+
/** Determines the auth mode from explicit AuthFlow or the credential keys present. */
|
|
187
|
+
ResolveAuthMode(config) {
|
|
188
|
+
if (config.AuthFlow === 'oauth2')
|
|
189
|
+
return 'oauth2';
|
|
190
|
+
if (config.AuthFlow === 'oauth1-tba')
|
|
191
|
+
return 'oauth1-tba';
|
|
192
|
+
if (config.BearerToken || config.AccessToken || config.RefreshToken)
|
|
193
|
+
return 'oauth2';
|
|
194
|
+
return 'oauth1-tba';
|
|
195
|
+
}
|
|
196
|
+
// ── Auth ─────────────────────────────────────────────────────────────
|
|
197
|
+
async Authenticate(companyIntegration, contextUser) {
|
|
198
|
+
const config = await this.ParseConfig(companyIntegration, contextUser);
|
|
199
|
+
const mode = this.ResolveAuthMode(config);
|
|
200
|
+
const subdomain = config.AccountID.toLowerCase().replace(/_/g, '-');
|
|
201
|
+
// Host derives from AccountID by default; an explicit Configuration.HostBaseURL (custom
|
|
202
|
+
// gateway / mock vendor in e2e) wins when present. Trailing slash trimmed so APIPath joins clean.
|
|
203
|
+
const hostBaseURL = (config.HostBaseURL && config.HostBaseURL.trim().length > 0)
|
|
204
|
+
? config.HostBaseURL.replace(/\/+$/, '')
|
|
205
|
+
: `https://${subdomain}.suitetalk.api.netsuite.com`;
|
|
206
|
+
const realm = config.AccountID.replace(/-/g, '_').toUpperCase();
|
|
207
|
+
let bearerToken = '';
|
|
208
|
+
if (mode === 'oauth2') {
|
|
209
|
+
bearerToken = await this.ResolveBearerToken(config);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
this.AssertTBACredentials(config);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
Token: bearerToken,
|
|
216
|
+
TokenType: mode === 'oauth2' ? 'Bearer' : 'OAuth1',
|
|
217
|
+
Config: config,
|
|
218
|
+
Mode: mode,
|
|
219
|
+
HostBaseURL: hostBaseURL,
|
|
220
|
+
Realm: realm,
|
|
221
|
+
BearerToken: bearerToken,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
AssertTBACredentials(config) {
|
|
225
|
+
if (!config.ConsumerKey || !config.ConsumerSecret || !config.TokenID || !config.TokenSecret) {
|
|
226
|
+
throw new Error('NetSuite TBA (OAuth 1.0a) requires ConsumerKey, ConsumerSecret, TokenID, and TokenSecret.');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Resolves a bearer token for OAuth2 mode. A pre-minted BearerToken/AccessToken is used directly
|
|
231
|
+
* (NetSuite OAuth2 access tokens are supplied by the operator). When a RefreshToken + TokenURL +
|
|
232
|
+
* client credentials are present, the shared OAuth2TokenManager refreshes — crypto-free token round-trip.
|
|
233
|
+
*/
|
|
234
|
+
async ResolveBearerToken(config) {
|
|
235
|
+
const direct = config.BearerToken ?? config.AccessToken;
|
|
236
|
+
if (config.RefreshToken && config.TokenURL && config.ClientID && config.ClientSecret) {
|
|
237
|
+
const token = await this.oauth2Manager.GetAccessToken({
|
|
238
|
+
TokenURL: config.TokenURL,
|
|
239
|
+
ClientId: config.ClientID,
|
|
240
|
+
ClientSecret: config.ClientSecret,
|
|
241
|
+
RefreshToken: config.RefreshToken,
|
|
242
|
+
}, 'refresh_token');
|
|
243
|
+
return token.AccessToken;
|
|
244
|
+
}
|
|
245
|
+
if (direct)
|
|
246
|
+
return direct;
|
|
247
|
+
throw new Error('NetSuite OAuth2 mode requires either a BearerToken/AccessToken, or RefreshToken + TokenURL + ClientID + ClientSecret.');
|
|
248
|
+
}
|
|
249
|
+
// ── Headers ──────────────────────────────────────────────────────────
|
|
98
250
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
251
|
+
* Base headers WITHOUT an Authorization value — the auth header is per-request (OAuth 1.0a signs
|
|
252
|
+
* each URL+method) so it's injected in MakeHTTPRequest, not here. The base class calls BuildHeaders
|
|
253
|
+
* for its generic CRUD / pagination GETs; MakeHTTPRequest stamps the signed/bearer Authorization.
|
|
254
|
+
*/
|
|
255
|
+
BuildHeaders(_auth) {
|
|
256
|
+
return {
|
|
257
|
+
'Accept': 'application/json',
|
|
258
|
+
'User-Agent': 'MemberJunction-Integration/1.0',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/** Builds the Authorization header for a specific URL + method (TBA signs per request; OAuth2 is static). */
|
|
262
|
+
BuildAuthorizationHeader(auth, url, method) {
|
|
263
|
+
if (auth.Mode === 'oauth2') {
|
|
264
|
+
return `Bearer ${auth.BearerToken}`;
|
|
265
|
+
}
|
|
266
|
+
return OAuth1aSigner.BuildAuthorizationHeader({
|
|
267
|
+
ConsumerKey: auth.Config.ConsumerKey,
|
|
268
|
+
ConsumerSecret: auth.Config.ConsumerSecret,
|
|
269
|
+
TokenId: auth.Config.TokenID,
|
|
270
|
+
TokenSecret: auth.Config.TokenSecret,
|
|
271
|
+
Method: method,
|
|
272
|
+
Url: url,
|
|
273
|
+
Realm: auth.Realm,
|
|
274
|
+
SignatureMethod: 'HMAC-SHA256',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// ── URL building ─────────────────────────────────────────────────────
|
|
278
|
+
/** Base URL = host root; the IO's APIPath already carries the full /services/rest/... path. */
|
|
279
|
+
GetBaseURL(_companyIntegration, auth) {
|
|
280
|
+
return auth.HostBaseURL;
|
|
281
|
+
}
|
|
282
|
+
// ── HTTP transport ───────────────────────────────────────────────────
|
|
283
|
+
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
284
|
+
const nsAuth = auth;
|
|
285
|
+
const effectiveHeaders = { ...headers, 'Authorization': this.BuildAuthorizationHeader(nsAuth, url, method) };
|
|
286
|
+
if (body !== undefined && method !== 'GET' && method !== 'DELETE') {
|
|
287
|
+
// Record bodies use NetSuite's vendor media type unless a caller (SuiteQL) set its own.
|
|
288
|
+
if (!effectiveHeaders['Content-Type'])
|
|
289
|
+
effectiveHeaders['Content-Type'] = NS_RECORD_MEDIA_TYPE;
|
|
290
|
+
}
|
|
291
|
+
for (let attempt = 0; attempt <= NS_MAX_RETRIES; attempt++) {
|
|
292
|
+
let response;
|
|
293
|
+
try {
|
|
294
|
+
const opts = {
|
|
295
|
+
method,
|
|
296
|
+
headers: effectiveHeaders,
|
|
297
|
+
signal: AbortSignal.timeout(NS_REQUEST_TIMEOUT_MS),
|
|
298
|
+
};
|
|
299
|
+
if (body !== undefined && method !== 'GET' && method !== 'DELETE') {
|
|
300
|
+
opts.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
301
|
+
}
|
|
302
|
+
response = await fetch(url, opts);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (attempt < NS_MAX_RETRIES && this.IsTransientNetworkError(err)) {
|
|
306
|
+
await this.Sleep(this.BackoffDelay(attempt));
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
// Concurrency-limit rejection (429) or transient 5xx — back off + retry within budget.
|
|
312
|
+
if ((response.status === 429 || response.status >= 500) && attempt < NS_MAX_RETRIES) {
|
|
313
|
+
const retryAfter = this.ParseRetryAfterHeader(response);
|
|
314
|
+
await this.Sleep(retryAfter ?? this.BackoffDelay(attempt));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
return this.BuildRESTResponse(response);
|
|
318
|
+
}
|
|
319
|
+
throw new Error(`NetSuite request failed after ${NS_MAX_RETRIES + 1} attempts: ${url}`);
|
|
320
|
+
}
|
|
321
|
+
async BuildRESTResponse(response) {
|
|
322
|
+
const hdrs = {};
|
|
323
|
+
response.headers.forEach((v, k) => { hdrs[k.toLowerCase()] = v; });
|
|
324
|
+
const ct = hdrs['content-type'] ?? '';
|
|
325
|
+
let parsed;
|
|
326
|
+
if (ct.includes('json')) {
|
|
327
|
+
const text = await response.text();
|
|
328
|
+
parsed = text.length > 0 ? JSON.parse(text) : {};
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
parsed = await response.text();
|
|
332
|
+
}
|
|
333
|
+
return { Status: response.status, Body: parsed, Headers: hdrs };
|
|
334
|
+
}
|
|
335
|
+
ParseRetryAfterHeader(response) {
|
|
336
|
+
const ra = response.headers.get('retry-after');
|
|
337
|
+
if (!ra)
|
|
338
|
+
return undefined;
|
|
339
|
+
const secs = parseInt(ra, 10);
|
|
340
|
+
return Number.isFinite(secs) ? secs * 1000 : undefined;
|
|
341
|
+
}
|
|
342
|
+
IsTransientNetworkError(err) {
|
|
343
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
344
|
+
return /econnreset|etimedout|enotfound|eai_again|socket hang up|network|fetch failed|timeout/i.test(msg);
|
|
345
|
+
}
|
|
346
|
+
BackoffDelay(attempt) {
|
|
347
|
+
return Math.min(1000 * Math.pow(2, attempt) + Math.floor(Math.random() * 500), 30_000);
|
|
348
|
+
}
|
|
349
|
+
Sleep(ms) {
|
|
350
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
351
|
+
}
|
|
352
|
+
// ── Response parsing ─────────────────────────────────────────────────
|
|
353
|
+
/** Strips the NetSuite HATEOAS envelope: { items: [...] } → the items array; bare object → [object]. */
|
|
354
|
+
NormalizeResponse(rawBody, _responseDataKey) {
|
|
355
|
+
if (Array.isArray(rawBody))
|
|
356
|
+
return rawBody;
|
|
357
|
+
if (!rawBody || typeof rawBody !== 'object')
|
|
358
|
+
return [];
|
|
359
|
+
const body = rawBody;
|
|
360
|
+
if (Array.isArray(body.items))
|
|
361
|
+
return body.items;
|
|
362
|
+
// A single-record GET returns the record object directly (with a `links` HATEOAS array).
|
|
363
|
+
if (Object.keys(body).length > 0)
|
|
364
|
+
return [body];
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
/** Offset pagination: hasMore flag + offset advance (or the next-link href when present). */
|
|
368
|
+
ExtractPaginationInfo(rawBody, _paginationType, _currentPage, currentOffset, pageSize) {
|
|
369
|
+
const body = (rawBody && typeof rawBody === 'object') ? rawBody : {};
|
|
370
|
+
const records = this.NormalizeResponse(rawBody, null);
|
|
371
|
+
// Prefer the explicit hasMore flag; fall back to "got a full page".
|
|
372
|
+
const hasMore = body.hasMore ?? (records.length >= (pageSize || NS_DEFAULT_PAGE_SIZE));
|
|
373
|
+
const nextOffset = this.NextOffsetFromLinks(body) ?? currentOffset + records.length;
|
|
374
|
+
return {
|
|
375
|
+
HasMore: hasMore,
|
|
376
|
+
NextOffset: nextOffset,
|
|
377
|
+
TotalRecords: body.totalResults,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/** Reads the `offset` query param from the HATEOAS rel='next' link when the envelope provides one. */
|
|
381
|
+
NextOffsetFromLinks(body) {
|
|
382
|
+
const next = body.links?.find(l => l.rel === 'next');
|
|
383
|
+
if (!next?.href)
|
|
384
|
+
return undefined;
|
|
385
|
+
const qIdx = next.href.indexOf('?');
|
|
386
|
+
if (qIdx === -1)
|
|
387
|
+
return undefined;
|
|
388
|
+
const off = new URLSearchParams(next.href.substring(qIdx + 1)).get('offset');
|
|
389
|
+
const n = off != null ? parseInt(off, 10) : NaN;
|
|
390
|
+
return Number.isFinite(n) ? n : undefined;
|
|
391
|
+
}
|
|
392
|
+
// ── TestConnection ───────────────────────────────────────────────────
|
|
393
|
+
/** Validates credentials + host by running a trivial SuiteQL probe (no record dependency). */
|
|
394
|
+
async TestConnection(companyIntegration, contextUser) {
|
|
395
|
+
try {
|
|
396
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
397
|
+
// serverTime is the cheapest authenticated read and seeds the incremental watermark.
|
|
398
|
+
const url = `${auth.HostBaseURL}${NS_SERVERTIME_PATH}`;
|
|
399
|
+
const headers = this.BuildHeaders(auth);
|
|
400
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
401
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
402
|
+
const st = response.Body;
|
|
403
|
+
const t = st?.serverTime ?? st?.time ?? 'unknown';
|
|
404
|
+
return { Success: true, Message: `Connected to NetSuite (${auth.Mode}); server time ${t}` };
|
|
405
|
+
}
|
|
406
|
+
if (response.Status === 401 || response.Status === 403) {
|
|
407
|
+
return { Success: false, Message: `NetSuite authentication failed: HTTP ${response.Status}` };
|
|
408
|
+
}
|
|
409
|
+
return { Success: false, Message: `NetSuite returned HTTP ${response.Status} from ${NS_SERVERTIME_PATH}` };
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
return { Success: false, Message: err instanceof Error ? err.message : String(err) };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// ── Discovery (auth-gated metadata-catalog MECHANISM — no baked catalog) ──
|
|
416
|
+
/**
|
|
417
|
+
* Discovers objects by UNIONING the Declared baseline with the live metadata-catalog (case-2
|
|
418
|
+
* auth-gated discovery). The Declared metadata (200+ standard record types + the sublist-access-path
|
|
419
|
+
* children that the catalog never lists) is the floor — it is ALWAYS surfaced so a refresh can't drop
|
|
420
|
+
* it. The catalog's record-type SLUGS (`phonecall`) are name-aligned back to the Declared name via the
|
|
421
|
+
* cached Configuration.recordTypeSlug so a slug never duplicates an existing Declared object; an
|
|
422
|
+
* unknown slug passes through as a NEW custom record. Net: standard universe always present + tenant
|
|
423
|
+
* customs added. Non-authoritative (DiscoveryIsAuthoritative=false), so no object is ever deactivated.
|
|
101
424
|
*/
|
|
102
425
|
async DiscoverObjects(companyIntegration, contextUser) {
|
|
426
|
+
const declared = await this.GetDeclaredObjects(companyIntegration, contextUser);
|
|
427
|
+
const slugToDeclaredName = this.BuildSlugToDeclaredNameMap(companyIntegration.IntegrationID);
|
|
428
|
+
const declaredNamesLower = new Set(declared.map(o => o.Name.toLowerCase()));
|
|
103
429
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
104
|
-
const
|
|
105
|
-
const headers = this.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (response.Status !== 200) {
|
|
110
|
-
console.warn(`[NetSuite] Metadata catalog returned ${response.Status}, falling back to SuiteQL discovery`);
|
|
111
|
-
return this.DiscoverObjectsViaSuiteQL(auth);
|
|
430
|
+
const url = `${auth.HostBaseURL}${NS_METADATA_CATALOG_PATH}`;
|
|
431
|
+
const headers = { ...this.BuildHeaders(auth), 'Accept': 'application/schema+json' };
|
|
432
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
433
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
434
|
+
throw new Error(`NetSuite metadata-catalog returned HTTP ${response.Status} — cannot enumerate record types.`);
|
|
112
435
|
}
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
SupportsWrite: true, // All REST record types support full CRUD
|
|
436
|
+
const slugs = this.ExtractCatalogNames(response.Body);
|
|
437
|
+
const out = [...declared];
|
|
438
|
+
for (const slug of slugs) {
|
|
439
|
+
// Name-align to the Declared name this slug maps to; for an UNKNOWN slug (a new custom record)
|
|
440
|
+
// keep the slug VERBATIM as the Name — the slug is the API-addressable record-type identity, so
|
|
441
|
+
// it must survive untouched for the discovered custom's CRUD/SuiteQL paths. (Label is humanized.)
|
|
442
|
+
const name = slugToDeclaredName.get(slug.toLowerCase()) ?? slug;
|
|
443
|
+
if (declaredNamesLower.has(name.toLowerCase()))
|
|
444
|
+
continue; // already in the Declared floor
|
|
445
|
+
declaredNamesLower.add(name.toLowerCase());
|
|
446
|
+
out.push({
|
|
447
|
+
Name: name,
|
|
448
|
+
Label: this.HumanizeName(slug),
|
|
449
|
+
Description: `NetSuite ${this.HumanizeName(slug)} record`,
|
|
450
|
+
SupportsIncrementalSync: true,
|
|
451
|
+
SupportsWrite: true,
|
|
130
452
|
});
|
|
131
453
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The Declared (cached) baseline objects — the floor DiscoverObjects always surfaces. Seam over the
|
|
458
|
+
* base class's cache read so the union logic is unit-testable without a DB-backed engine.
|
|
459
|
+
*/
|
|
460
|
+
GetDeclaredObjects(companyIntegration, contextUser) {
|
|
461
|
+
return super.DiscoverObjects(companyIntegration, contextUser);
|
|
462
|
+
}
|
|
463
|
+
/** Maps each Declared IO's Configuration.recordTypeSlug → its Declared Name (for catalog name-alignment). */
|
|
464
|
+
BuildSlugToDeclaredNameMap(integrationID) {
|
|
465
|
+
const map = new Map();
|
|
466
|
+
const objects = IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integrationID);
|
|
467
|
+
for (const obj of objects) {
|
|
468
|
+
const slug = this.ReadRecordTypeSlug(obj.Configuration);
|
|
469
|
+
if (slug)
|
|
470
|
+
map.set(slug.toLowerCase(), obj.Name);
|
|
135
471
|
}
|
|
136
|
-
return
|
|
472
|
+
return map;
|
|
137
473
|
}
|
|
138
474
|
/**
|
|
139
|
-
*
|
|
475
|
+
* Resolves the metadata-catalog slug for an object name: the cached IO's Configuration.recordTypeSlug
|
|
476
|
+
* when present (the authoritative slug, e.g. `phonecall` for `Phone Call`), else a name collapse.
|
|
140
477
|
*/
|
|
141
|
-
|
|
142
|
-
const query = `SELECT scriptid, name FROM customrecordtype UNION ALL SELECT 'customer' as scriptid, 'Customer' as name FROM dual`;
|
|
143
|
-
const url = `${auth.QueryBaseURL}/suiteql?limit=1000&offset=0`;
|
|
144
|
-
const headers = this.BuildRequestHeaders(auth, url, 'POST', true);
|
|
478
|
+
ResolveRecordTypeSlug(integrationID, objectName) {
|
|
145
479
|
try {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
for (const item of (body.items ?? [])) {
|
|
151
|
-
const name = String(item['scriptid'] ?? item['name'] ?? '');
|
|
152
|
-
if (name) {
|
|
153
|
-
this.recordTypeCache.set(name.toLowerCase(), { name, href: '' });
|
|
154
|
-
results.push({
|
|
155
|
-
Name: name, Label: this.FormatDisplayName(name),
|
|
156
|
-
Description: `NetSuite ${this.FormatDisplayName(name)}`,
|
|
157
|
-
SupportsIncrementalSync: true, SupportsWrite: true,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
if (results.length > 0)
|
|
162
|
-
return results;
|
|
163
|
-
}
|
|
480
|
+
const obj = this.GetCachedObject(integrationID, objectName);
|
|
481
|
+
const slug = this.ReadRecordTypeSlug(obj.Configuration);
|
|
482
|
+
if (slug)
|
|
483
|
+
return slug;
|
|
164
484
|
}
|
|
165
485
|
catch {
|
|
166
|
-
//
|
|
486
|
+
// object not in cache (a freshly-discovered custom) — fall through to name collapse
|
|
487
|
+
}
|
|
488
|
+
return objectName.toLowerCase().replace(/\s+/g, '');
|
|
489
|
+
}
|
|
490
|
+
/** Tolerantly reads Configuration.recordTypeSlug from an IO's Configuration JSON. */
|
|
491
|
+
ReadRecordTypeSlug(configuration) {
|
|
492
|
+
if (!configuration)
|
|
493
|
+
return undefined;
|
|
494
|
+
try {
|
|
495
|
+
const cfg = JSON.parse(configuration);
|
|
496
|
+
return typeof cfg.recordTypeSlug === 'string' ? cfg.recordTypeSlug : undefined;
|
|
167
497
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
GetFallbackObjects() {
|
|
172
|
-
const knownTypes = [
|
|
173
|
-
'customer', 'vendor', 'employee', 'contact', 'partner', 'lead', 'prospect',
|
|
174
|
-
'salesorder', 'invoice', 'purchaseorder', 'journalentry', 'creditmemo',
|
|
175
|
-
'vendorbill', 'vendorcredit', 'vendorpayment', 'customerpayment', 'customerrefund',
|
|
176
|
-
'cashsale', 'cashrefund', 'check', 'deposit', 'estimate', 'opportunity',
|
|
177
|
-
'expensereport', 'returnauthorization', 'itemfulfillment', 'itemreceipt',
|
|
178
|
-
'transferorder', 'workorder', 'assemblybuild', 'inventoryadjustment',
|
|
179
|
-
'account', 'department', 'classification', 'location', 'subsidiary', 'currency',
|
|
180
|
-
'inventoryitem', 'noninventoryitem', 'serviceitem', 'otherchargeitem',
|
|
181
|
-
'discountitem', 'assemblyitem', 'kititem', 'giftcertificateitem',
|
|
182
|
-
'task', 'phonecall', 'calendarevent', 'note', 'message',
|
|
183
|
-
'supportcase', 'issue', 'solution',
|
|
184
|
-
'job', 'charge', 'timeentry',
|
|
185
|
-
'term', 'paymentmethod', 'salestaxitem',
|
|
186
|
-
'file', 'folder',
|
|
187
|
-
];
|
|
188
|
-
for (const name of knownTypes) {
|
|
189
|
-
this.recordTypeCache.set(name, { name, href: '' });
|
|
498
|
+
catch {
|
|
499
|
+
return undefined;
|
|
190
500
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
501
|
+
}
|
|
502
|
+
/** Pulls record-type names from the catalog's `items[].name` or `links[].href` trailing segment. */
|
|
503
|
+
ExtractCatalogNames(body) {
|
|
504
|
+
const out = [];
|
|
505
|
+
for (const it of body.items ?? []) {
|
|
506
|
+
if (it.name)
|
|
507
|
+
out.push(it.name);
|
|
508
|
+
}
|
|
509
|
+
if (out.length === 0) {
|
|
510
|
+
for (const lk of body.links ?? []) {
|
|
511
|
+
if (lk.rel === 'self' || !lk.href)
|
|
512
|
+
continue;
|
|
513
|
+
const seg = lk.href.split('/').filter(Boolean).pop();
|
|
514
|
+
if (seg)
|
|
515
|
+
out.push(seg);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
198
519
|
}
|
|
199
520
|
/**
|
|
200
|
-
* Discovers fields for a record type
|
|
201
|
-
*
|
|
521
|
+
* Discovers fields for a record type from the metadata-catalog JSON-schema for that type. The schema
|
|
522
|
+
* `properties` map → fields; `required` → IsRequired; `readOnly`/computed markers → IsReadOnly; the
|
|
523
|
+
* universal numeric `id` is the PK. Falls back to the cached Declared fields when the catalog can't
|
|
524
|
+
* describe this type.
|
|
202
525
|
*/
|
|
203
526
|
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
204
527
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
213
|
-
return
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
catch {
|
|
217
|
-
// Fall through to sample-record approach
|
|
528
|
+
// Prefer the IO's Configuration.recordTypeSlug (authoritative slug); else collapse the name.
|
|
529
|
+
const slug = this.ResolveRecordTypeSlug(companyIntegration.IntegrationID, objectName);
|
|
530
|
+
const url = `${auth.HostBaseURL}${NS_METADATA_CATALOG_PATH}/${encodeURIComponent(slug)}`;
|
|
531
|
+
const headers = { ...this.BuildHeaders(auth), 'Accept': 'application/schema+json' };
|
|
532
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
533
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
534
|
+
const parsed = this.ParseFieldSchema(response.Body);
|
|
535
|
+
if (parsed.length > 0)
|
|
536
|
+
return parsed;
|
|
218
537
|
}
|
|
219
|
-
//
|
|
220
|
-
return
|
|
538
|
+
// Could not describe via catalog — defer to the Declared metadata (cache) the base class returns.
|
|
539
|
+
return super.DiscoverFields(companyIntegration, objectName, contextUser);
|
|
221
540
|
}
|
|
222
|
-
|
|
541
|
+
/** Maps a JSON-schema describe body to ExternalFieldSchema[] (provable-only: only stated flags). */
|
|
542
|
+
ParseFieldSchema(body) {
|
|
543
|
+
if (!body || typeof body !== 'object')
|
|
544
|
+
return [];
|
|
223
545
|
const schema = body;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
546
|
+
if (!schema.properties)
|
|
547
|
+
return [];
|
|
548
|
+
const required = new Set(schema.required ?? []);
|
|
227
549
|
const fields = [];
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
const nsType = String(prop['type'] ?? prop['format'] ?? 'string');
|
|
550
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
551
|
+
const isId = name === 'id';
|
|
231
552
|
fields.push({
|
|
232
553
|
Name: name,
|
|
233
|
-
Label: String(prop['title'] ?? name),
|
|
234
|
-
Description:
|
|
235
|
-
DataType: this.
|
|
236
|
-
IsRequired:
|
|
237
|
-
|
|
238
|
-
|
|
554
|
+
Label: String(prop['title'] ?? this.HumanizeName(name)),
|
|
555
|
+
Description: typeof prop['description'] === 'string' ? prop['description'] : undefined,
|
|
556
|
+
DataType: this.MapSchemaType(prop),
|
|
557
|
+
IsRequired: required.has(name),
|
|
558
|
+
IsPrimaryKey: isId || undefined,
|
|
559
|
+
IsUniqueKey: isId,
|
|
560
|
+
IsReadOnly: prop['readOnly'] === true || isId,
|
|
239
561
|
});
|
|
240
562
|
}
|
|
241
|
-
// Ensure PK exists
|
|
242
563
|
if (!fields.some(f => f.Name === 'id')) {
|
|
243
564
|
fields.unshift({
|
|
244
565
|
Name: 'id', Label: 'Internal ID', Description: 'NetSuite internal record ID',
|
|
245
|
-
DataType: 'string', IsRequired: false, IsUniqueKey: true, IsReadOnly: true,
|
|
566
|
+
DataType: 'string', IsRequired: false, IsPrimaryKey: true, IsUniqueKey: true, IsReadOnly: true,
|
|
246
567
|
});
|
|
247
568
|
}
|
|
248
569
|
return fields;
|
|
249
570
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const sample = (body.items ?? [])[0];
|
|
258
|
-
if (sample) {
|
|
259
|
-
const fields = [];
|
|
260
|
-
for (const [key, value] of Object.entries(sample)) {
|
|
261
|
-
if (key === 'links')
|
|
262
|
-
continue; // Skip HATEOAS links
|
|
263
|
-
fields.push({
|
|
264
|
-
Name: key,
|
|
265
|
-
Label: this.FormatDisplayName(key),
|
|
266
|
-
Description: '',
|
|
267
|
-
DataType: this.InferTypeFromValue(value),
|
|
268
|
-
IsRequired: false,
|
|
269
|
-
IsUniqueKey: key === 'id',
|
|
270
|
-
IsReadOnly: key === 'id' || key === 'lastmodifieddate' || key === 'datecreated',
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
return fields;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
// Fall through
|
|
279
|
-
}
|
|
280
|
-
return this.DefaultFields();
|
|
281
|
-
}
|
|
282
|
-
DefaultFields() {
|
|
283
|
-
return [
|
|
284
|
-
{ Name: 'id', Label: 'Internal ID', Description: 'NetSuite internal record ID', DataType: 'string', IsRequired: false, IsUniqueKey: true, IsReadOnly: true },
|
|
285
|
-
{ Name: 'lastmodifieddate', Label: 'Last Modified', Description: 'Last modification date', DataType: 'datetime', IsRequired: false, IsUniqueKey: false, IsReadOnly: true },
|
|
286
|
-
];
|
|
287
|
-
}
|
|
288
|
-
MapNSFieldType(nsType) {
|
|
289
|
-
const lower = nsType.toLowerCase();
|
|
290
|
-
if (lower.includes('integer') || lower === 'int')
|
|
571
|
+
/** Maps a JSON-schema property to a generic source type. */
|
|
572
|
+
MapSchemaType(prop) {
|
|
573
|
+
const t = String(prop['type'] ?? '').toLowerCase();
|
|
574
|
+
const fmt = String(prop['format'] ?? '').toLowerCase();
|
|
575
|
+
if (fmt.includes('date') || fmt.includes('time'))
|
|
576
|
+
return 'datetime';
|
|
577
|
+
if (t === 'integer')
|
|
291
578
|
return 'number';
|
|
292
|
-
if (
|
|
579
|
+
if (t === 'number')
|
|
293
580
|
return 'decimal';
|
|
294
|
-
if (
|
|
581
|
+
if (t === 'boolean')
|
|
295
582
|
return 'boolean';
|
|
296
|
-
if (lower.includes('date') || lower.includes('time'))
|
|
297
|
-
return 'datetime';
|
|
298
583
|
return 'string';
|
|
299
584
|
}
|
|
300
|
-
|
|
301
|
-
if (value === null || value === undefined)
|
|
302
|
-
return 'string';
|
|
303
|
-
if (typeof value === 'number')
|
|
304
|
-
return Number.isInteger(value) ? 'number' : 'decimal';
|
|
305
|
-
if (typeof value === 'boolean')
|
|
306
|
-
return 'boolean';
|
|
307
|
-
if (typeof value === 'string') {
|
|
308
|
-
if (/^\d{4}-\d{2}-\d{2}/.test(value))
|
|
309
|
-
return 'datetime';
|
|
310
|
-
return 'string';
|
|
311
|
-
}
|
|
312
|
-
return 'string';
|
|
313
|
-
}
|
|
314
|
-
FormatDisplayName(name) {
|
|
315
|
-
// Convert camelCase/lowercase to Title Case: "salesorder" → "Sales Order"
|
|
585
|
+
HumanizeName(name) {
|
|
316
586
|
return name
|
|
317
|
-
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
318
|
-
.replace(/
|
|
319
|
-
.replace(/[_-]/g, ' ') // snake_case → snake case
|
|
587
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
588
|
+
.replace(/[_-]/g, ' ')
|
|
320
589
|
.trim()
|
|
321
590
|
.split(/\s+/)
|
|
322
|
-
.map(w => w.charAt(0).toUpperCase() + w.slice(1)
|
|
323
|
-
.join(' ');
|
|
324
|
-
}
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const queryBaseURL = `https://${accountLower}.suitetalk.api.netsuite.com/services/rest/query/v1`;
|
|
331
|
-
return {
|
|
332
|
-
Token: '', TokenType: 'OAuth1', Config: config,
|
|
333
|
-
RecordBaseURL: recordBaseURL, QueryBaseURL: queryBaseURL,
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
async ParseConfig(companyIntegration, contextUser, provider) {
|
|
337
|
-
const credentialID = companyIntegration.CredentialID;
|
|
338
|
-
if (credentialID) {
|
|
339
|
-
const md = provider ?? new Metadata();
|
|
340
|
-
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
341
|
-
const loaded = await credential.Load(credentialID);
|
|
342
|
-
if (loaded && credential.Values) {
|
|
343
|
-
const parsed = JSON.parse(credential.Values);
|
|
344
|
-
if (parsed['AccountID'] && parsed['ConsumerKey']) {
|
|
345
|
-
return {
|
|
346
|
-
AccountID: parsed['AccountID'], ConsumerKey: parsed['ConsumerKey'],
|
|
347
|
-
ConsumerSecret: parsed['ConsumerSecret'] ?? '',
|
|
348
|
-
TokenID: parsed['TokenID'] ?? '', TokenSecret: parsed['TokenSecret'] ?? '',
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
const configJson = companyIntegration.Configuration;
|
|
354
|
-
if (configJson) {
|
|
355
|
-
const parsed = JSON.parse(configJson);
|
|
356
|
-
return {
|
|
357
|
-
AccountID: parsed['AccountID'] ?? '', ConsumerKey: parsed['ConsumerKey'] ?? '',
|
|
358
|
-
ConsumerSecret: parsed['ConsumerSecret'] ?? '',
|
|
359
|
-
TokenID: parsed['TokenID'] ?? '', TokenSecret: parsed['TokenSecret'] ?? '',
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
throw new Error('No NetSuite credentials found. Set AccountID, ConsumerKey, ConsumerSecret, TokenID, TokenSecret.');
|
|
363
|
-
}
|
|
364
|
-
// ─── OAuth 1.0 TBA Signature ────────────────────────────────────────
|
|
365
|
-
BuildOAuth1Header(config, method, url) {
|
|
366
|
-
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
367
|
-
const nonce = randomBytes(16).toString('hex');
|
|
368
|
-
const oauthParams = {
|
|
369
|
-
oauth_consumer_key: config.ConsumerKey, oauth_nonce: nonce,
|
|
370
|
-
oauth_signature_method: 'HMAC-SHA256', oauth_timestamp: timestamp,
|
|
371
|
-
oauth_token: config.TokenID, oauth_version: '1.0',
|
|
372
|
-
};
|
|
373
|
-
let baseURL = url;
|
|
374
|
-
const queryPart = {};
|
|
375
|
-
const qIdx = url.indexOf('?');
|
|
376
|
-
if (qIdx !== -1) {
|
|
377
|
-
baseURL = url.substring(0, qIdx);
|
|
378
|
-
new URLSearchParams(url.substring(qIdx + 1)).forEach((v, k) => { queryPart[k] = v; });
|
|
379
|
-
}
|
|
380
|
-
const allParams = { ...oauthParams, ...queryPart };
|
|
381
|
-
const normalizedParams = Object.keys(allParams).sort()
|
|
382
|
-
.map(k => `${this.PercentEncode(k)}=${this.PercentEncode(allParams[k])}`).join('&');
|
|
383
|
-
const sigBaseString = `${method.toUpperCase()}&${this.PercentEncode(baseURL)}&${this.PercentEncode(normalizedParams)}`;
|
|
384
|
-
const signingKey = `${this.PercentEncode(config.ConsumerSecret)}&${this.PercentEncode(config.TokenSecret)}`;
|
|
385
|
-
const signature = createHmac('sha256', signingKey).update(sigBaseString).digest('base64');
|
|
386
|
-
const accountRealm = config.AccountID.replace('-', '_').toUpperCase();
|
|
387
|
-
const headerParams = { ...oauthParams, oauth_signature: signature };
|
|
388
|
-
const parts = Object.entries(headerParams).map(([k, v]) => `${k}="${v}"`).join(', ');
|
|
389
|
-
return `OAuth realm="${accountRealm}", ${parts}`;
|
|
390
|
-
}
|
|
391
|
-
PercentEncode(str) {
|
|
392
|
-
return encodeURIComponent(str).replace(/!/g, '%21').replace(/'/g, '%27')
|
|
393
|
-
.replace(/\(/g, '%28').replace(/\)/g, '%29').replace(/\*/g, '%2A');
|
|
394
|
-
}
|
|
395
|
-
// ─── TestConnection ────────────────────────────────────────────────
|
|
396
|
-
async TestConnection(companyIntegration, contextUser) {
|
|
397
|
-
try {
|
|
398
|
-
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
399
|
-
const url = `${auth.RecordBaseURL}/customer?limit=1`;
|
|
400
|
-
const headers = this.BuildRequestHeaders(auth, url, 'GET');
|
|
401
|
-
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
402
|
-
if (response.Status === 200) {
|
|
403
|
-
const body = response.Body;
|
|
404
|
-
return { Success: true, Message: `Connected to NetSuite — ${body.totalResults ?? 0} customers` };
|
|
405
|
-
}
|
|
406
|
-
return { Success: false, Message: `API returned ${response.Status}` };
|
|
407
|
-
}
|
|
408
|
-
catch (err) {
|
|
409
|
-
return { Success: false, Message: err instanceof Error ? err.message : String(err) };
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
// ─── URL Building ──────────────────────────────────────────────────
|
|
413
|
-
GetBaseURL(_companyIntegration, auth) {
|
|
414
|
-
return auth.RecordBaseURL;
|
|
415
|
-
}
|
|
416
|
-
BuildPaginatedURL(basePath, _obj, _page, offset, _cursor) {
|
|
417
|
-
const sep = basePath.includes('?') ? '&' : '?';
|
|
418
|
-
return `${basePath}${sep}limit=${NS_PAGE_SIZE}&offset=${offset}`;
|
|
419
|
-
}
|
|
420
|
-
// ─── Response Parsing ──────────────────────────────────────────────
|
|
421
|
-
NormalizeResponse(rawBody, _responseDataKey) {
|
|
422
|
-
if (Array.isArray(rawBody))
|
|
423
|
-
return rawBody;
|
|
424
|
-
const body = rawBody;
|
|
425
|
-
if (Array.isArray(body['items']))
|
|
426
|
-
return body['items'];
|
|
427
|
-
if (body && Object.keys(body).length > 0)
|
|
428
|
-
return [body];
|
|
429
|
-
return [];
|
|
430
|
-
}
|
|
431
|
-
ExtractPaginationInfo(rawBody, _paginationType, _currentPage, currentOffset, _pageSize) {
|
|
432
|
-
const body = rawBody;
|
|
433
|
-
const records = this.NormalizeResponse(rawBody, null);
|
|
434
|
-
return { HasMore: body.hasMore ?? false, NextOffset: currentOffset + records.length };
|
|
435
|
-
}
|
|
436
|
-
// ─── Headers ───────────────────────────────────────────────────────
|
|
437
|
-
BuildHeaders(_auth) {
|
|
438
|
-
return { 'Accept': 'application/json', 'User-Agent': 'MemberJunction-Integration/1.0' };
|
|
439
|
-
}
|
|
440
|
-
BuildRequestHeaders(auth, url, method, hasBody = false) {
|
|
441
|
-
const headers = {
|
|
442
|
-
'Authorization': this.BuildOAuth1Header(auth.Config, method, url),
|
|
443
|
-
'Accept': 'application/json', 'User-Agent': 'MemberJunction-Integration/1.0',
|
|
444
|
-
};
|
|
445
|
-
if (hasBody)
|
|
446
|
-
headers['Content-Type'] = 'application/json';
|
|
447
|
-
return headers;
|
|
448
|
-
}
|
|
449
|
-
// ─── FetchChanges — generic for ANY record type ────────────────────
|
|
591
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
592
|
+
.join(' ') || name;
|
|
593
|
+
}
|
|
594
|
+
// ── FetchChanges — SuiteQL (the one idiosyncratic override) ──────────
|
|
595
|
+
//
|
|
596
|
+
// OVERRIDE RATIONALE: NetSuite reads run through SuiteQL (POST with SQL in the body + the required
|
|
597
|
+
// `Prefer: transient` header) so the lastModifiedDate incremental predicate can be expressed — a
|
|
598
|
+
// shape the base class's generic GET path cannot produce. CRUD stays on the generic per-operation path.
|
|
450
599
|
async FetchChanges(ctx) {
|
|
600
|
+
const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
601
|
+
const fields = this.GetCachedFields(obj.ID);
|
|
451
602
|
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
452
|
-
const
|
|
603
|
+
const cfg = this.ReadIOConfig(obj);
|
|
604
|
+
// SuiteQL table = the IO's configured suiteQLTable; fall back to the catalog slug (collapsed name).
|
|
605
|
+
const table = cfg.suiteQLTable ?? this.ResolveRecordTypeSlug(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
606
|
+
const watermarkField = obj.IncrementalWatermarkField ?? 'lastModifiedDate';
|
|
453
607
|
const offset = ctx.CurrentOffset ?? 0;
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
const url = `${auth.QueryBaseURL}/suiteql?limit=${NS_PAGE_SIZE}&offset=${offset}`;
|
|
465
|
-
const headers = this.BuildRequestHeaders(auth, url, 'POST', true);
|
|
466
|
-
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, { q: query });
|
|
608
|
+
const pageSize = obj.DefaultPageSize ?? cfg.defaultPageSize ?? NS_DEFAULT_PAGE_SIZE;
|
|
609
|
+
const sql = this.BuildSuiteQL(table, watermarkField, ctx.WatermarkValue);
|
|
610
|
+
const url = `${auth.HostBaseURL}${NS_SUITEQL_PATH}?limit=${pageSize}&offset=${offset}`;
|
|
611
|
+
// SuiteQL bodies are plain JSON and REQUIRE the `Prefer: transient` header.
|
|
612
|
+
const headers = {
|
|
613
|
+
...this.BuildHeaders(auth),
|
|
614
|
+
'Content-Type': 'application/json',
|
|
615
|
+
'Prefer': 'transient',
|
|
616
|
+
};
|
|
617
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, { q: sql });
|
|
467
618
|
if (response.Status < 200 || response.Status >= 300) {
|
|
468
|
-
throw new Error(`NetSuite SuiteQL
|
|
619
|
+
throw new Error(`NetSuite SuiteQL read failed for "${ctx.ObjectName}": HTTP ${response.Status} — ${this.PreviewBody(response.Body)}`);
|
|
469
620
|
}
|
|
470
621
|
const body = response.Body;
|
|
471
|
-
const
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const modified = r['lastmodifieddate'];
|
|
479
|
-
if (modified && (!newWatermark || modified > newWatermark))
|
|
480
|
-
newWatermark = modified;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
622
|
+
const rawRecords = this.NormalizeResponse(body, null);
|
|
623
|
+
const pkFieldNames = fields.filter(f => f.IsPrimaryKey).map(f => f.Name);
|
|
624
|
+
const records = rawRecords.map(raw => this.BuildExternalRecord(raw, ctx.ObjectName, pkFieldNames));
|
|
625
|
+
const hasMore = body.hasMore ?? (rawRecords.length >= pageSize);
|
|
626
|
+
// Track the max watermark and persist it ONLY when the full result set has been drained
|
|
627
|
+
// (partial-failure semantics: a mid-iteration stop leaves the watermark unchanged).
|
|
628
|
+
const newWatermark = !hasMore ? this.MaxWatermark(rawRecords, watermarkField) : undefined;
|
|
483
629
|
return {
|
|
484
|
-
Records:
|
|
485
|
-
|
|
486
|
-
|
|
630
|
+
Records: records,
|
|
631
|
+
HasMore: hasMore,
|
|
632
|
+
NextOffset: offset + rawRecords.length,
|
|
633
|
+
NewWatermarkValue: newWatermark,
|
|
487
634
|
};
|
|
488
635
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
636
|
+
/** Builds the SuiteQL SELECT, applying the incremental watermark predicate when a watermark exists. */
|
|
637
|
+
BuildSuiteQL(table, watermarkField, watermark) {
|
|
638
|
+
const where = watermark
|
|
639
|
+
? ` WHERE ${watermarkField} > ${this.QuoteSuiteQLDate(watermark)}`
|
|
640
|
+
: '';
|
|
641
|
+
return `SELECT * FROM ${table}${where} ORDER BY ${watermarkField}`;
|
|
642
|
+
}
|
|
643
|
+
/** Quotes a watermark value for SuiteQL — wraps a NetSuite datetime in TO_DATE/quotes, escaping quotes. */
|
|
644
|
+
QuoteSuiteQLDate(value) {
|
|
645
|
+
const escaped = value.replace(/'/g, "''");
|
|
646
|
+
return `'${escaped}'`;
|
|
647
|
+
}
|
|
648
|
+
/** Returns the maximum value of the watermark field across the batch (string compare on ISO timestamps). */
|
|
649
|
+
MaxWatermark(records, field) {
|
|
650
|
+
let max;
|
|
651
|
+
for (const r of records) {
|
|
652
|
+
const v = r[field];
|
|
653
|
+
if (typeof v === 'string' && v.length > 0 && (!max || v > max))
|
|
654
|
+
max = v;
|
|
501
655
|
}
|
|
502
|
-
return
|
|
656
|
+
return max;
|
|
503
657
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
const endpoint = ctx.ObjectName.toLowerCase();
|
|
518
|
-
const url = `${auth.RecordBaseURL}/${endpoint}/${ctx.ExternalID}`;
|
|
519
|
-
const headers = this.BuildRequestHeaders(auth, url, 'DELETE');
|
|
520
|
-
const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
|
|
521
|
-
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
522
|
-
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
|
|
523
|
-
}
|
|
524
|
-
return this.BuildCRUDError(response, 'DeleteRecord', ctx.ObjectName);
|
|
525
|
-
}
|
|
526
|
-
BuildCRUDError(response, operation, objectName) {
|
|
527
|
-
const bodyStr = typeof response.Body === 'string' ? response.Body : JSON.stringify(response.Body);
|
|
658
|
+
/**
|
|
659
|
+
* Builds an ExternalRecord carrying the FULL source record in Fields (full-record pass-through, so the
|
|
660
|
+
* framework's custom-column capture sees every key) and the universal `id` as the ExternalID. Mirrors
|
|
661
|
+
* the base class's composite-PK + content-hash fallback at the connector boundary for hand-built records.
|
|
662
|
+
*/
|
|
663
|
+
BuildExternalRecord(raw, objectType, pkFieldNames) {
|
|
664
|
+
const keys = pkFieldNames.length > 0 ? pkFieldNames : ['id'];
|
|
665
|
+
const parts = keys.map(k => {
|
|
666
|
+
const v = raw[k];
|
|
667
|
+
return v == null ? '' : String(v);
|
|
668
|
+
});
|
|
669
|
+
const allPresent = parts.every(p => p.length > 0);
|
|
670
|
+
const externalID = allPresent ? parts.join('|') : String(raw['id'] ?? '');
|
|
528
671
|
return {
|
|
529
|
-
|
|
530
|
-
|
|
672
|
+
ExternalID: externalID,
|
|
673
|
+
ObjectType: objectType,
|
|
674
|
+
Fields: raw,
|
|
531
675
|
};
|
|
532
676
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const response = await this.FetchWithTimeout(url, method, headers, body);
|
|
538
|
-
this.lastRequestTime = Date.now();
|
|
539
|
-
if (response.status === 429) {
|
|
540
|
-
const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
|
|
541
|
-
console.warn(`[NetSuite] Rate limited (429), backing off ${Math.round(delay)}ms`);
|
|
542
|
-
await this.Sleep(delay);
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
if (response.status >= 500 && attempt < MAX_RETRIES) {
|
|
546
|
-
const delay = Math.min(2000 * Math.pow(2, attempt), 60_000);
|
|
547
|
-
console.warn(`[NetSuite] Server error ${response.status}, retrying in ${delay}ms`);
|
|
548
|
-
await this.Sleep(delay);
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
const responseBody = await this.ParseBody(response);
|
|
552
|
-
return this.ToRESTResponse(response, responseBody);
|
|
553
|
-
}
|
|
554
|
-
throw new Error(`NetSuite request failed after ${MAX_RETRIES} retries: ${url}`);
|
|
555
|
-
}
|
|
556
|
-
async FetchWithTimeout(url, method, headers, body) {
|
|
557
|
-
const controller = new AbortController();
|
|
558
|
-
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
677
|
+
/** Reads the IO's Configuration JSON (suiteQLTable, defaultPageSize) tolerantly. */
|
|
678
|
+
ReadIOConfig(obj) {
|
|
679
|
+
if (!obj.Configuration)
|
|
680
|
+
return {};
|
|
559
681
|
try {
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
catch (err) {
|
|
566
|
-
if (err instanceof Error && err.name === 'AbortError')
|
|
567
|
-
throw new Error(`NetSuite request timed out: ${url}`);
|
|
568
|
-
throw err;
|
|
682
|
+
const cfg = JSON.parse(obj.Configuration);
|
|
683
|
+
return {
|
|
684
|
+
suiteQLTable: typeof cfg.suiteQLTable === 'string' ? cfg.suiteQLTable : undefined,
|
|
685
|
+
defaultPageSize: typeof cfg.defaultPageSize === 'number' ? cfg.defaultPageSize : undefined,
|
|
686
|
+
};
|
|
569
687
|
}
|
|
570
|
-
|
|
571
|
-
|
|
688
|
+
catch {
|
|
689
|
+
return {};
|
|
572
690
|
}
|
|
573
691
|
}
|
|
574
|
-
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
return response.json();
|
|
578
|
-
return response.text();
|
|
579
|
-
}
|
|
580
|
-
ToRESTResponse(response, body) {
|
|
581
|
-
const hdrs = {};
|
|
582
|
-
response.headers.forEach((v, k) => { hdrs[k.toLowerCase()] = v; });
|
|
583
|
-
return { Status: response.status, Body: body, Headers: hdrs };
|
|
584
|
-
}
|
|
585
|
-
async Throttle() {
|
|
586
|
-
const elapsed = Date.now() - this.lastRequestTime;
|
|
587
|
-
if (elapsed < MIN_REQUEST_INTERVAL_MS)
|
|
588
|
-
await this.Sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
|
|
589
|
-
}
|
|
590
|
-
Sleep(ms) {
|
|
591
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
592
|
-
}
|
|
593
|
-
// ─── Default Field Mappings ────────────────────────────────────────
|
|
594
|
-
GetDefaultFieldMappings(objectName) {
|
|
595
|
-
// Dynamic — uses discovered fields from cache or returns PK-only default
|
|
596
|
-
return [{ SourceFieldName: 'id', DestinationFieldName: 'id', IsKeyField: true }];
|
|
692
|
+
PreviewBody(body) {
|
|
693
|
+
const s = typeof body === 'string' ? body : JSON.stringify(body);
|
|
694
|
+
return s ? s.substring(0, 300) : '';
|
|
597
695
|
}
|
|
598
696
|
};
|
|
599
697
|
NetSuiteConnector = __decorate([
|
|
600
698
|
RegisterClass(BaseIntegrationConnector, 'NetSuiteConnector')
|
|
601
699
|
], NetSuiteConnector);
|
|
602
700
|
export { NetSuiteConnector };
|
|
603
|
-
// Tree-shaking prevention — REQUIRED
|
|
701
|
+
// Tree-shaking prevention — REQUIRED so @RegisterClass survives bundling.
|
|
604
702
|
export function LoadNetSuiteConnector() { }
|
|
605
703
|
//# sourceMappingURL=NetSuiteConnector.js.map
|