@memberjunction/integration-connectors 5.41.0 → 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.
Files changed (92) hide show
  1. package/dist/CventConnector.d.ts +146 -0
  2. package/dist/CventConnector.d.ts.map +1 -0
  3. package/dist/CventConnector.js +540 -0
  4. package/dist/CventConnector.js.map +1 -0
  5. package/dist/DynamicsDataverseConnector.d.ts +296 -0
  6. package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
  7. package/dist/DynamicsDataverseConnector.js +790 -0
  8. package/dist/DynamicsDataverseConnector.js.map +1 -0
  9. package/dist/FontevaConnector.d.ts +153 -0
  10. package/dist/FontevaConnector.d.ts.map +1 -0
  11. package/dist/FontevaConnector.js +421 -0
  12. package/dist/FontevaConnector.js.map +1 -0
  13. package/dist/GrowthZoneConnector.d.ts +123 -125
  14. package/dist/GrowthZoneConnector.d.ts.map +1 -1
  15. package/dist/GrowthZoneConnector.js +456 -499
  16. package/dist/GrowthZoneConnector.js.map +1 -1
  17. package/dist/HivebriteConnector.d.ts +151 -0
  18. package/dist/HivebriteConnector.d.ts.map +1 -0
  19. package/dist/HivebriteConnector.js +533 -0
  20. package/dist/HivebriteConnector.js.map +1 -0
  21. package/dist/IMISConnector.d.ts +112 -96
  22. package/dist/IMISConnector.d.ts.map +1 -1
  23. package/dist/IMISConnector.js +430 -417
  24. package/dist/IMISConnector.js.map +1 -1
  25. package/dist/MemberSuiteConnector.d.ts +185 -0
  26. package/dist/MemberSuiteConnector.d.ts.map +1 -0
  27. package/dist/MemberSuiteConnector.js +762 -0
  28. package/dist/MemberSuiteConnector.js.map +1 -0
  29. package/dist/NeonCRMConnector.d.ts +273 -0
  30. package/dist/NeonCRMConnector.d.ts.map +1 -0
  31. package/dist/NeonCRMConnector.js +1039 -0
  32. package/dist/NeonCRMConnector.js.map +1 -0
  33. package/dist/NetForumConnector.d.ts +140 -47
  34. package/dist/NetForumConnector.d.ts.map +1 -1
  35. package/dist/NetForumConnector.js +668 -395
  36. package/dist/NetForumConnector.js.map +1 -1
  37. package/dist/NetSuiteConnector.d.ts +116 -50
  38. package/dist/NetSuiteConnector.d.ts.map +1 -1
  39. package/dist/NetSuiteConnector.js +608 -510
  40. package/dist/NetSuiteConnector.js.map +1 -1
  41. package/dist/NimbleAMSConnector.d.ts +104 -11
  42. package/dist/NimbleAMSConnector.d.ts.map +1 -1
  43. package/dist/NimbleAMSConnector.js +552 -258
  44. package/dist/NimbleAMSConnector.js.map +1 -1
  45. package/dist/NoviConnector.d.ts +178 -0
  46. package/dist/NoviConnector.d.ts.map +1 -0
  47. package/dist/NoviConnector.js +566 -0
  48. package/dist/NoviConnector.js.map +1 -0
  49. package/dist/ORCIDConnector.d.ts +207 -0
  50. package/dist/ORCIDConnector.d.ts.map +1 -0
  51. package/dist/ORCIDConnector.js +708 -0
  52. package/dist/ORCIDConnector.js.map +1 -0
  53. package/dist/OpenWaterConnector.d.ts +173 -0
  54. package/dist/OpenWaterConnector.d.ts.map +1 -0
  55. package/dist/OpenWaterConnector.js +893 -0
  56. package/dist/OpenWaterConnector.js.map +1 -0
  57. package/dist/PathLMSConnector.d.ts +395 -0
  58. package/dist/PathLMSConnector.d.ts.map +1 -0
  59. package/dist/PathLMSConnector.js +1281 -0
  60. package/dist/PathLMSConnector.js.map +1 -0
  61. package/dist/PheedLoopConnector.d.ts +119 -0
  62. package/dist/PheedLoopConnector.d.ts.map +1 -0
  63. package/dist/PheedLoopConnector.js +429 -0
  64. package/dist/PheedLoopConnector.js.map +1 -0
  65. package/dist/PropFuelConnector.d.ts +135 -61
  66. package/dist/PropFuelConnector.d.ts.map +1 -1
  67. package/dist/PropFuelConnector.js +366 -467
  68. package/dist/PropFuelConnector.js.map +1 -1
  69. package/dist/RhythmConnector.d.ts +188 -0
  70. package/dist/RhythmConnector.d.ts.map +1 -0
  71. package/dist/RhythmConnector.js +784 -0
  72. package/dist/RhythmConnector.js.map +1 -0
  73. package/dist/SalesforceConnector.d.ts +69 -3
  74. package/dist/SalesforceConnector.d.ts.map +1 -1
  75. package/dist/SalesforceConnector.js +188 -166
  76. package/dist/SalesforceConnector.js.map +1 -1
  77. package/dist/SharePointConnector.d.ts +37 -30
  78. package/dist/SharePointConnector.d.ts.map +1 -1
  79. package/dist/SharePointConnector.js +163 -243
  80. package/dist/SharePointConnector.js.map +1 -1
  81. package/dist/YourMembershipConnector.d.ts.map +1 -1
  82. package/dist/YourMembershipConnector.js +13 -5
  83. package/dist/YourMembershipConnector.js.map +1 -1
  84. package/dist/index.d.ts +14 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +14 -3
  87. package/dist/index.js.map +1 -1
  88. package/package.json +8 -7
  89. package/dist/BettyConnector.d.ts +0 -288
  90. package/dist/BettyConnector.d.ts.map +0 -1
  91. package/dist/BettyConnector.js +0 -909
  92. package/dist/BettyConnector.js.map +0 -1
@@ -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 ERP platform.
8
+ * NetSuiteConnector — Integration connector for Oracle NetSuite (SuiteTalk REST Web Services).
9
9
  *
10
- * API Documentation: https://system.netsuite.com/help/helpcenter/en_US/APIs/REST_API_Browser/record/v1/2023.1/index.html
10
+ * API docs: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/ (SuiteTalk REST API Guide)
11
11
  *
12
- * Auth: OAuth 1.0 Token-Based Authentication (TBA) — non-expiring tokens.
13
- * Credentials: ConsumerKey, ConsumerSecret, TokenID, TokenSecret, AccountID
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
- * Discovery: DYNAMIC via GET /record/v1/metadata-catalog/
23
- * - Enumerates ALL available record types for the account (100+ standard types)
24
- * - Field metadata fetched per record type via GET /record/v1/metadata-catalog/{recordType}
25
- * - New record types (including custom records) are picked up automatically
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
- * API Categories:
28
- * - REST Record API (implemented, dynamic) — direct CRUD on ALL record types via metadata catalog
29
- * - SuiteQL API (implemented) — SQL-like queries for read + incremental sync
30
- * - RESTlet API (NOT implemented) — custom SuiteScript server-side endpoints, not standard
31
- * - SOAP/SuiteTalk SOAP (NOT implemented) — legacy XML, superseded by REST
32
- * - CSV Import API (NOT implemented) — file-based batch upload, not API-driven
33
- * - No webhooks/CDC — use SuiteQL lastmodifieddate polling instead
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 { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
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 NS_PAGE_SIZE = 1000;
41
- const MAX_RETRIES = 3;
42
- const REQUEST_TIMEOUT_MS = 60_000;
43
- const MIN_REQUEST_INTERVAL_MS = 100;
44
- // Transaction record types in SuiteQL use the `transaction` table with a type filter.
45
- // Non-transaction records query their own table (same name as REST endpoint).
46
- const NS_TRANSACTION_TYPES = {
47
- 'salesorder': 'SalesOrd', 'invoice': 'CustInvc', 'purchaseorder': 'PurchOrd',
48
- 'journalentry': 'Journal', 'creditmemo': 'CustCred', 'vendorbill': 'VendBill',
49
- 'vendorcredit': 'VendCred', 'vendorpayment': 'VendPymt', 'customerpayment': 'CustPymt',
50
- 'customerrefund': 'CustRfnd', 'customerdeposit': 'CustDep', 'cashsale': 'CashSale',
51
- 'cashrefund': 'CashRfnd', 'check': 'Check', 'deposit': 'Deposit',
52
- 'expensereport': 'ExpRept', 'estimate': 'Estimate', 'returnauthorization': 'RtnAuth',
53
- 'itemfulfillment': 'ItemShip', 'itemreceipt': 'ItemRcpt', 'transferorder': 'TrnfrOrd',
54
- 'workorder': 'WorkOrd', 'assemblybuild': 'Build', 'inventoryadjustment': 'InvAdjst',
55
- 'inventorytransfer': 'InvTrnfr', 'depositapplication': 'DepAppl',
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.lastRequestTime = 0;
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
- GetIntegrationObjects() {
70
- // Returns discovered objects with minimal fields — DiscoverFields() fills the rest
71
- const objects = [];
72
- for (const [, item] of this.recordTypeCache) {
73
- objects.push({
74
- Name: item.name,
75
- DisplayName: this.FormatDisplayName(item.name),
76
- Description: `NetSuite ${this.FormatDisplayName(item.name)} record`,
77
- SupportsWrite: true,
78
- Fields: [
79
- { Name: 'id', DisplayName: 'Internal ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'NetSuite internal record ID' },
80
- { Name: 'lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modification date' },
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 objects;
85
- }
86
- GetActionGeneratorConfig() {
87
- const config = super.GetActionGeneratorConfig();
88
- if (!config)
89
- return null;
90
- config.IconClass = 'fa-solid fa-file-invoice-dollar';
91
- config.CategoryDescription = 'NetSuite ERP — all record types discovered dynamically via metadata catalog';
92
- config.ParentCategoryName = 'ERP/Finance';
93
- config.IncludeSearch = true;
94
- config.IncludeList = true;
95
- return config;
96
- }
97
- // ─── Discovery (DYNAMIC via metadata-catalog) ──────────────────────
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
- * Discovers ALL available record types by calling the NetSuite REST metadata-catalog API.
100
- * This covers 100+ standard record types plus any custom records (customrecord_{id}).
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 catalogURL = `${auth.RecordBaseURL}/metadata-catalog/`;
105
- const headers = this.BuildRequestHeaders(auth, catalogURL, 'GET');
106
- // Add header to request JSON-schema format with type listing
107
- headers['Accept'] = 'application/schema+json';
108
- const response = await this.MakeHTTPRequest(auth, catalogURL, 'GET', headers);
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 body = response.Body;
114
- // The metadata-catalog returns { items: [{ name, href }, ...] } or { links: [...] }
115
- const items = body['items']
116
- ?? body['links']
117
- ?? [];
118
- this.recordTypeCache.clear();
119
- const results = [];
120
- for (const item of items) {
121
- if (!item.name)
122
- continue;
123
- this.recordTypeCache.set(item.name.toLowerCase(), item);
124
- results.push({
125
- Name: item.name,
126
- Label: this.FormatDisplayName(item.name),
127
- Description: `NetSuite ${this.FormatDisplayName(item.name)} record`,
128
- SupportsIncrementalSync: true, // All NS records support lastmodifieddate via SuiteQL
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
- // If catalog returned nothing, fall back to SuiteQL
133
- if (results.length === 0) {
134
- return this.DiscoverObjectsViaSuiteQL(auth);
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 results;
472
+ return map;
137
473
  }
138
474
  /**
139
- * Fallback: discover record types via SuiteQL query against the recordType table.
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
- async DiscoverObjectsViaSuiteQL(auth) {
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 response = await this.MakeHTTPRequest(auth, url, 'POST', headers, { q: query });
147
- if (response.Status === 200) {
148
- const body = response.Body;
149
- const results = [];
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
- // Fall through to hardcoded fallback
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
- // Last resort: return a well-known set so the connector is usable even if discovery fails
169
- return this.GetFallbackObjects();
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
- return knownTypes.map(name => ({
192
- Name: name,
193
- Label: this.FormatDisplayName(name),
194
- Description: `NetSuite ${this.FormatDisplayName(name)} record`,
195
- SupportsIncrementalSync: true,
196
- SupportsWrite: true,
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 by fetching its metadata via the REST API.
201
- * Falls back to fetching one sample record and inferring field names/types.
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
- const recordType = objectName.toLowerCase();
206
- // Try metadata endpoint first: GET /record/v1/metadata-catalog/{recordType}
207
- try {
208
- const metaURL = `${auth.RecordBaseURL}/metadata-catalog/${recordType}`;
209
- const headers = this.BuildRequestHeaders(auth, metaURL, 'GET');
210
- headers['Accept'] = 'application/schema+json';
211
- const response = await this.MakeHTTPRequest(auth, metaURL, 'GET', headers);
212
- if (response.Status === 200) {
213
- return this.ParseFieldMetadata(response.Body);
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
- // Fallback: fetch one record and infer fields from the response
220
- return this.InferFieldsFromSample(auth, recordType);
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
- ParseFieldMetadata(body) {
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
- const properties = schema['properties'];
225
- if (!properties)
226
- return this.DefaultFields();
546
+ if (!schema.properties)
547
+ return [];
548
+ const required = new Set(schema.required ?? []);
227
549
  const fields = [];
228
- const requiredFields = schema['required'] ?? [];
229
- for (const [name, prop] of Object.entries(properties)) {
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: String(prop['description'] ?? ''),
235
- DataType: this.MapNSFieldType(nsType),
236
- IsRequired: requiredFields.includes(name),
237
- IsUniqueKey: name === 'id',
238
- IsReadOnly: prop['readOnly'] === true,
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
- async InferFieldsFromSample(auth, recordType) {
251
- try {
252
- const url = `${auth.RecordBaseURL}/${recordType}?limit=1`;
253
- const headers = this.BuildRequestHeaders(auth, url, 'GET');
254
- const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
255
- if (response.Status === 200) {
256
- const body = response.Body;
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 (lower.includes('float') || lower.includes('double') || lower.includes('currency') || lower.includes('decimal'))
579
+ if (t === 'number')
293
580
  return 'decimal';
294
- if (lower.includes('boolean'))
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
- InferTypeFromValue(value) {
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') // camelCase → camel Case
318
- .replace(/([A-Z]+)/g, ' $1') // ABCDef → ABC Def
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).toLowerCase())
323
- .join(' ');
324
- }
325
- // ─── Auth ──────────────────────────────────────────────────────────
326
- async Authenticate(companyIntegration, contextUser) {
327
- const config = await this.ParseConfig(companyIntegration, contextUser);
328
- const accountLower = config.AccountID.toLowerCase().replace('_', '-');
329
- const recordBaseURL = `https://${accountLower}.suitetalk.api.netsuite.com/services/rest/record/v1`;
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 recordType = ctx.ObjectName.toLowerCase();
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
- // Determine SuiteQL table: transactions use `transaction` table with type filter
455
- const transType = NS_TRANSACTION_TYPES[recordType];
456
- const suiteqlTable = transType ? 'transaction' : recordType;
457
- const conditions = [];
458
- if (transType)
459
- conditions.push(`type = '${transType}'`);
460
- if (ctx.WatermarkValue)
461
- conditions.push(`lastmodifieddate >= '${ctx.WatermarkValue}'`);
462
- const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
463
- const query = `SELECT * FROM ${suiteqlTable}${whereClause} ORDER BY lastmodifieddate`;
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 error for ${recordType}: ${response.Status}`);
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 records = body.items ?? [];
472
- const externalRecords = records.map(r => ({
473
- ExternalID: String(r['id'] ?? ''), ObjectType: ctx.ObjectName, Fields: r,
474
- }));
475
- let newWatermark;
476
- if (!body.hasMore) {
477
- for (const r of records) {
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: externalRecords, HasMore: body.hasMore ?? false,
485
- NextOffset: offset + records.length,
486
- NewWatermarkValue: !body.hasMore ? newWatermark : undefined,
630
+ Records: records,
631
+ HasMore: hasMore,
632
+ NextOffset: offset + rawRecords.length,
633
+ NewWatermarkValue: newWatermark,
487
634
  };
488
635
  }
489
- // ─── CRUD — generic for ANY record type ────────────────────────────
490
- async CreateRecord(ctx) {
491
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
492
- const endpoint = ctx.ObjectName.toLowerCase();
493
- const url = `${auth.RecordBaseURL}/${endpoint}`;
494
- const headers = this.BuildRequestHeaders(auth, url, 'POST', true);
495
- const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, ctx.Attributes);
496
- if (response.Status >= 200 && response.Status < 300) {
497
- const location = response.Headers?.['location'] ?? '';
498
- const idMatch = location.match(/\/(\d+)$/);
499
- const id = idMatch ? idMatch[1] : String(response.Body?.['id'] ?? '');
500
- return { Success: true, ExternalID: id, StatusCode: response.Status };
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 this.BuildCRUDError(response, 'CreateRecord', ctx.ObjectName);
656
+ return max;
503
657
  }
504
- async UpdateRecord(ctx) {
505
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
506
- const endpoint = ctx.ObjectName.toLowerCase();
507
- const url = `${auth.RecordBaseURL}/${endpoint}/${ctx.ExternalID}`;
508
- const headers = this.BuildRequestHeaders(auth, url, 'PATCH', true);
509
- const response = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, ctx.Attributes);
510
- if (response.Status >= 200 && response.Status < 300) {
511
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
512
- }
513
- return this.BuildCRUDError(response, 'UpdateRecord', ctx.ObjectName);
514
- }
515
- async DeleteRecord(ctx) {
516
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
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
- Success: false, ExternalID: '', StatusCode: response.Status,
530
- ErrorMessage: `${operation} failed for ${objectName}: HTTP ${response.Status} — ${bodyStr?.substring(0, 300)}`,
672
+ ExternalID: externalID,
673
+ ObjectType: objectType,
674
+ Fields: raw,
531
675
  };
532
676
  }
533
- // ─── HTTP Transport ────────────────────────────────────────────────
534
- async MakeHTTPRequest(_auth, url, method, headers, body) {
535
- await this.Throttle();
536
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
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 opts = { method, headers, signal: controller.signal };
561
- if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH'))
562
- opts.body = JSON.stringify(body);
563
- return await fetch(url, opts);
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
- finally {
571
- clearTimeout(timeoutId);
688
+ catch {
689
+ return {};
572
690
  }
573
691
  }
574
- async ParseBody(response) {
575
- const ct = response.headers.get('content-type') ?? '';
576
- if (ct.includes('json'))
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