@memberjunction/connector-neon-crm 1.0.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/NeonCRMConnector.d.ts +272 -0
- package/dist/NeonCRMConnector.js +1039 -0
- package/dist/NeonCRMConnector.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { type UserInfo } from '@memberjunction/core';
|
|
2
|
+
import type { MJCompanyIntegrationEntity, MJIntegrationObjectEntity, MJIntegrationObjectFieldEntity } from '@memberjunction/core-entities';
|
|
3
|
+
import { BaseRESTIntegrationConnector, type RESTAuthContext, type RESTResponse, type PaginationState, type PaginationType, type ConnectionTestResult, type ExternalObjectSchema, type ExternalFieldSchema, type FetchContext, type FetchBatchResult, type RateLimitPolicy, type CreateRecordContext, type CRUDResult } from '@memberjunction/integration-engine';
|
|
4
|
+
/**
|
|
5
|
+
* Neon CRM connection configuration, parsed from the attached MJ Credential (preferred)
|
|
6
|
+
* or the CompanyIntegration.Configuration JSON. Field names are read case-insensitively.
|
|
7
|
+
* NONE of these values are read at build time — resolved from the bound credential at runtime.
|
|
8
|
+
*/
|
|
9
|
+
export interface NeonCRMConnectionConfig {
|
|
10
|
+
/** Neon organization id — the HTTP Basic userid. */
|
|
11
|
+
OrgID: string;
|
|
12
|
+
/** Neon API key — the HTTP Basic password. */
|
|
13
|
+
APIKey: string;
|
|
14
|
+
/** API base URL (defaults to https://api.neoncrm.com/v2). */
|
|
15
|
+
BaseURL: string;
|
|
16
|
+
/** NEON-API-VERSION header value (defaults to 2.11). */
|
|
17
|
+
APIVersion: string;
|
|
18
|
+
/** Maximum retries for transient (429/503/network) failures. Default 3. */
|
|
19
|
+
MaxRetries: number;
|
|
20
|
+
/** HTTP request timeout in ms. Default 30000. */
|
|
21
|
+
RequestTimeoutMs: number;
|
|
22
|
+
}
|
|
23
|
+
export declare class NeonCRMConnector extends BaseRESTIntegrationConnector {
|
|
24
|
+
/** Cached auth context for the current sync run. */
|
|
25
|
+
private authCache;
|
|
26
|
+
/** Current watermark value, available to FetchChanges-driven filtering. */
|
|
27
|
+
private currentWatermark;
|
|
28
|
+
get IntegrationName(): string;
|
|
29
|
+
get SupportsCreate(): boolean;
|
|
30
|
+
get SupportsUpdate(): boolean;
|
|
31
|
+
get SupportsDelete(): boolean;
|
|
32
|
+
/** True when any cached IntegrationObject satisfies the predicate. []→false when the
|
|
33
|
+
* engine cache is unavailable (capability probed before configuration) — fail-safe read-only. */
|
|
34
|
+
private anyObjectDeclares;
|
|
35
|
+
get RateLimitPolicy(): RateLimitPolicy | null;
|
|
36
|
+
/** Parse Neon's Retry-After (seconds or http-date) into ms for the engine's AIMD bucket. */
|
|
37
|
+
ExtractRetryAfterMs(error: unknown): number | undefined;
|
|
38
|
+
protected TransformRecord(raw: Record<string, unknown>, obj: MJIntegrationObjectEntity, _fields: MJIntegrationObjectFieldEntity[]): Record<string, unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* Lifts a nested `individualAccount.accountId` / `companyAccount.accountId` to a top-level
|
|
41
|
+
* `accountId` WITHOUT overwriting an existing top-level value and WITHOUT dropping the nested
|
|
42
|
+
* blobs (full-record pass-through). Returns the input unchanged when there is nothing to lift.
|
|
43
|
+
* Reused by {@link TransformRecord} (Account direct sync) and by the access-path descent's
|
|
44
|
+
* door-PK resolution (so a Consent leaf can be stamped with its account's id — DEFECT 2).
|
|
45
|
+
*/
|
|
46
|
+
private liftNestedAccountId;
|
|
47
|
+
/** Reads a scalar `accountId` from a nested individual/company account blob, if present. */
|
|
48
|
+
private readNestedAccountId;
|
|
49
|
+
CreateRecord(ctx: CreateRecordContext): Promise<CRUDResult>;
|
|
50
|
+
/** Whether an error is a request timeout / aborted fetch (distinct from a clean non-2xx response). */
|
|
51
|
+
private isTimeoutError;
|
|
52
|
+
/**
|
|
53
|
+
* HTTP Basic authentication. Builds the `Basic base64(orgId:apiKey)` header via the
|
|
54
|
+
* shared auth-helper (no inline base64). Credential bytes are resolved at runtime.
|
|
55
|
+
*/
|
|
56
|
+
protected Authenticate(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<RESTAuthContext>;
|
|
57
|
+
/** Sends the Basic auth header + NEON-API-VERSION + JSON content negotiation on every request. */
|
|
58
|
+
protected BuildHeaders(auth: RESTAuthContext): Record<string, string>;
|
|
59
|
+
protected GetBaseURL(_companyIntegration: MJCompanyIntegrationEntity, auth: RESTAuthContext): string;
|
|
60
|
+
/**
|
|
61
|
+
* Normalizes Neon CRM responses. Handles the real shapes:
|
|
62
|
+
* 1. List/search envelope `{ <dataKey>: [...], pagination: {...} }` — the dataKey is the
|
|
63
|
+
* resource collection (e.g. `accounts`, `searchResults`, `donations`, `memberships`).
|
|
64
|
+
* When ResponseDataKey is set on the IO we read it; otherwise we auto-detect the first
|
|
65
|
+
* array-valued property that is NOT the `pagination` envelope.
|
|
66
|
+
* 2. Raw array at the root.
|
|
67
|
+
* 3. Single object — per-id GET endpoints return ONE record (often itself wrapped, e.g.
|
|
68
|
+
* `{ accountId, individualAccount: {...} }`); kept as a single-element list.
|
|
69
|
+
*
|
|
70
|
+
* The FULL source record passes through (no field filtering) so the framework's custom-column
|
|
71
|
+
* capture sees everything Neon returned.
|
|
72
|
+
*/
|
|
73
|
+
protected NormalizeResponse(rawBody: unknown, responseDataKey: string | null): Record<string, unknown>[];
|
|
74
|
+
/**
|
|
75
|
+
* Finds the records array inside a Neon list/search envelope. Neon names the collection
|
|
76
|
+
* after the resource (`accounts`, `donations`, `memberships`, `searchResults`, …) and always
|
|
77
|
+
* carries a sibling `pagination` object. We pick the first array-valued property that is not
|
|
78
|
+
* `pagination`. Returns null when no collection array is present (a single-object response).
|
|
79
|
+
*/
|
|
80
|
+
private findCollectionArray;
|
|
81
|
+
/**
|
|
82
|
+
* Derives Neon page-number pagination state. Neon returns a `pagination` envelope with
|
|
83
|
+
* `currentPage` (0-based), `totalPages`, `totalResults`. More pages remain while
|
|
84
|
+
* currentPage + 1 < totalPages. Falls back to an empty-page terminator when no envelope.
|
|
85
|
+
*/
|
|
86
|
+
protected ExtractPaginationInfo(rawBody: unknown, _paginationType: PaginationType, currentPage: number, currentOffset: number, _pageSize: number): PaginationState;
|
|
87
|
+
/**
|
|
88
|
+
* Emits Neon page-number pagination params: `currentPage` (0-based) + `pageSize` (capped at
|
|
89
|
+
* the vendor's 200 ceiling). currentPage is page-1 because the base loop counts from 1.
|
|
90
|
+
*/
|
|
91
|
+
protected BuildPaginatedURL(basePath: string, obj: MJIntegrationObjectEntity, page: number, _offset: number, _cursor?: string, effectivePageSize?: number): string;
|
|
92
|
+
/**
|
|
93
|
+
* Executes an HTTP request with retry/backoff for 429/503 and transient network errors.
|
|
94
|
+
* Parses Neon's Retry-After header into the error so ExtractRetryAfterMs can surface it.
|
|
95
|
+
* NOTE: retry is applied to GET/idempotent reads and to non-financial writes only — the
|
|
96
|
+
* CreateRecord override above handles the financial reconcile-before-retry contract.
|
|
97
|
+
*/
|
|
98
|
+
protected MakeHTTPRequest(auth: RESTAuthContext, url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
|
|
99
|
+
/**
|
|
100
|
+
* Tests connectivity by listing one account via GET /accounts?currentPage=0&pageSize=1.
|
|
101
|
+
* A 2xx confirms the Basic credentials (org id + API key) + base URL are valid. 401/403 are
|
|
102
|
+
* surfaced with clear messages (auth failure path); a network error is surfaced too.
|
|
103
|
+
*/
|
|
104
|
+
TestConnection(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ConnectionTestResult>;
|
|
105
|
+
/**
|
|
106
|
+
* Discovers the full object universe from the IntegrationEngineBase cache (the Declared
|
|
107
|
+
* metadata seeded from Neon's credential-free OAS3 spec). NEVER a hardcoded catalog; never
|
|
108
|
+
* sampled at build. When no Declared metadata is loaded (a credential-free static self-check
|
|
109
|
+
* with no DB-backed engine), this throws explicitly so credential-free tiers SKIP honestly
|
|
110
|
+
* rather than misread an empty result as catalog drift.
|
|
111
|
+
*/
|
|
112
|
+
DiscoverObjects(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ExternalObjectSchema[]>;
|
|
113
|
+
/** Discovers fields for an object from the cached Declared metadata. */
|
|
114
|
+
DiscoverFields(companyIntegration: MJCompanyIntegrationEntity, objectName: string, contextUser: UserInfo): Promise<ExternalFieldSchema[]>;
|
|
115
|
+
/**
|
|
116
|
+
* Enumerates an object's records per the THREE metadata-driven enumeration modes (read from the
|
|
117
|
+
* IO's Configuration), then advances the watermark from the returned records on the FINAL batch
|
|
118
|
+
* only (partial-failure-safe — a mid-batch failure leaves the watermark unchanged so the next sync
|
|
119
|
+
* resumes from the same point). Watermark advancement reads the latest
|
|
120
|
+
* `timestamps.lastModifiedDateTime` (Neon's documented incremental cursor).
|
|
121
|
+
*
|
|
122
|
+
* Mode 1 — Direct collection, GET → delegate to the base GET pagination path (super.FetchChanges).
|
|
123
|
+
* Mode 2 — Direct collection, POST → POST the Configuration.ListBody to APIPath, paginate via body.
|
|
124
|
+
* Mode 3 — Access-path descent → list the DOOR, then descend the nesting chain in memory.
|
|
125
|
+
*
|
|
126
|
+
* Mode is inferred from the IO's parsed Configuration; a missing/GET-direct Configuration keeps the
|
|
127
|
+
* exact prior behavior (the 34 direct-GET objects ride the base path unchanged).
|
|
128
|
+
*/
|
|
129
|
+
FetchChanges(ctx: FetchContext): Promise<FetchBatchResult>;
|
|
130
|
+
/** Routes the fetch to the GET-direct base path, POST-search listing, or access-path descent. */
|
|
131
|
+
private dispatchEnumeration;
|
|
132
|
+
/** Parses the IO's Configuration JSON into a typed NeonObjectConfig (GET-direct default on absence). */
|
|
133
|
+
private parseObjectConfig;
|
|
134
|
+
/** Narrows an unknown AccessPath blob to the typed shape (provable-only — undefined when absent). */
|
|
135
|
+
private coerceAccessPath;
|
|
136
|
+
/** True when this object's door must be LISTED via POST (search). */
|
|
137
|
+
private isPostList;
|
|
138
|
+
private coerceString;
|
|
139
|
+
private coerceRecord;
|
|
140
|
+
/**
|
|
141
|
+
* Lists a direct-collection object whose door is a POST `/…/search` endpoint. POSTs the
|
|
142
|
+
* Configuration.ListBody (parsed JSON) to APIPath, parses the same Neon envelope as GET, and
|
|
143
|
+
* paginates by injecting the page into the POST body's `pagination` object
|
|
144
|
+
* (`{"pagination":{"currentPage":N,"pageSize":M}}`). Returns a single full batch.
|
|
145
|
+
*/
|
|
146
|
+
private fetchViaPostSearch;
|
|
147
|
+
/**
|
|
148
|
+
* Lists the DOOR (via GET or POST per the access path), then descends the in-memory nesting chain
|
|
149
|
+
* to collect the LEAF records of `obj`. Each leaf carries the FULL leaf object in `Fields`
|
|
150
|
+
* (full-record pass-through), is tagged with the resolved ancestor FK id(s), and uses its own
|
|
151
|
+
* declared PK for identity (the synthetic content-hash fallback covers PK-less leaves).
|
|
152
|
+
*/
|
|
153
|
+
private fetchViaAccessPath;
|
|
154
|
+
/**
|
|
155
|
+
* Lists the access-path door, choosing GET or POST-search per the access path's listMethod.
|
|
156
|
+
* NOTE: NO server-side watermark filter is applied here. A date filter on the DOOR would narrow
|
|
157
|
+
* by the PARENT's last-modified date, which is NOT the LEAF object's watermark — filtering doors
|
|
158
|
+
* could drop leaf records whose owning parent wasn't modified recently (lossy). For nested/descent
|
|
159
|
+
* objects, content-hash narrowing remains the correct, lossless incremental strategy.
|
|
160
|
+
*/
|
|
161
|
+
private listDoor;
|
|
162
|
+
/** Splits a nesting chain string ("A -> b[] -> c") into typed segments (IsList for `[]`). */
|
|
163
|
+
private parseNestingChain;
|
|
164
|
+
/**
|
|
165
|
+
* Descends the nesting segments from a single door record IN MEMORY, collecting the leaf records,
|
|
166
|
+
* CARRYING the ancestor FK tags down the WHOLE chain (DEFECT 2). A `[]` segment expands an
|
|
167
|
+
* array-valued field (iterate each element); a plain segment dives into an object-valued field
|
|
168
|
+
* (single child). Records and primitives are skipped gracefully. Recurses so ≥2-level chains
|
|
169
|
+
* (e.g. pledges[] -> pledgePayments[]) are supported.
|
|
170
|
+
*
|
|
171
|
+
* `fkTagFields` is the leaf's set of ancestor-FK field names; `inheritedTags` is the accumulator
|
|
172
|
+
* seeded with the DOOR (root ancestor) record's resolved PK value(s). At EACH descended node we
|
|
173
|
+
* re-collect FK values present on that node (so a nearer ancestor — e.g. an intermediate Pledge —
|
|
174
|
+
* overrides a farther one), then at the leaf we stamp every accumulated tag the leaf does NOT
|
|
175
|
+
* already carry (the leaf's own value always wins). This makes the door's `accountId` reach a
|
|
176
|
+
* Consent leaf in `Account -> individualAccount -> consent`, while preserving the existing
|
|
177
|
+
* immediate-parent / 1-level behavior.
|
|
178
|
+
*/
|
|
179
|
+
private descendNesting;
|
|
180
|
+
/** Merges an intermediate node's own ancestor-FK values into the accumulator (node value wins). */
|
|
181
|
+
private mergeNodeTags;
|
|
182
|
+
/** Stamps accumulated ancestor FK id(s) onto a leaf, never overwriting an id the leaf already carries. */
|
|
183
|
+
private stampInheritedTags;
|
|
184
|
+
/**
|
|
185
|
+
* Resolves, from the leaf object's own IOFs, the FK field names that point at an ancestor object
|
|
186
|
+
* (RelatedIntegrationObjectID set). These are the columns to populate from an ancestor record so the
|
|
187
|
+
* leaf links back to its owner.
|
|
188
|
+
*/
|
|
189
|
+
private resolveAncestorFKFields;
|
|
190
|
+
/**
|
|
191
|
+
* Reads each ancestor-FK value from an ancestor record so it can be stamped onto the leaf. Applies
|
|
192
|
+
* the nested-account-id lift first so the DOOR Account record's nested `accountId` (DEFECT 1) is
|
|
193
|
+
* found — then reads the matching FK key (e.g. `accountId`, `pledgeId`). Neon ancestor records carry
|
|
194
|
+
* their own id under a key whose name matches the leaf's FK field name.
|
|
195
|
+
*/
|
|
196
|
+
private collectParentTags;
|
|
197
|
+
/** Lists a door via GET, paginating to exhaustion (Neon 0-based currentPage). Returns all records. */
|
|
198
|
+
private listAllViaGet;
|
|
199
|
+
/**
|
|
200
|
+
* Lists a door via POST search, paginating to exhaustion by injecting `{pagination:{currentPage,pageSize}}`
|
|
201
|
+
* into the POST body (Neon search pagination). Returns all records.
|
|
202
|
+
*/
|
|
203
|
+
private listAllViaPost;
|
|
204
|
+
/** Builds the POST search body with the page injected into the `pagination` envelope. */
|
|
205
|
+
private buildSearchBody;
|
|
206
|
+
/**
|
|
207
|
+
* Builds the effective POST-search `ListBody` for the incremental pass: when a watermark is
|
|
208
|
+
* present AND the door has a resolvable last-modified SEARCH FIELD, appends a
|
|
209
|
+
* `{ field, operator: GREATER_AND_EQUAL, value }` criterion to `searchFields` (the OAS3
|
|
210
|
+
* SearchRequest shape) so the API returns ONLY records changed at/after the watermark. On first
|
|
211
|
+
* sync (no watermark) — or for a door with no documented search field — returns the configured
|
|
212
|
+
* ListBody unchanged (full pull; content-hash narrowing remains the lossless fallback).
|
|
213
|
+
*
|
|
214
|
+
* Provable-only: a criterion is emitted ONLY when the search-field name is known — from the
|
|
215
|
+
* per-connection `Configuration.WatermarkSearchField` override or the documented
|
|
216
|
+
* {@link DOOR_WATERMARK_SEARCH_FIELD} map. A criterion is NEVER appended to a `searchFields` the
|
|
217
|
+
* connector already carries one for (idempotent), and the existing authored searchFields are
|
|
218
|
+
* preserved.
|
|
219
|
+
*/
|
|
220
|
+
private buildIncrementalListBody;
|
|
221
|
+
/**
|
|
222
|
+
* Resolves the door's last-modified SEARCH FIELD display name (the OAS3 SearchCriteria.field
|
|
223
|
+
* value), preferring the per-connection `Configuration.WatermarkSearchField` override, then the
|
|
224
|
+
* documented {@link DOOR_WATERMARK_SEARCH_FIELD} map keyed by the door path. Returns undefined
|
|
225
|
+
* when neither knows the door (⇒ no server-side filter).
|
|
226
|
+
*/
|
|
227
|
+
private resolveWatermarkSearchField;
|
|
228
|
+
/**
|
|
229
|
+
* Formats an ISO watermark timestamp to Neon's documented date-search value (`yyyy-MM-dd`).
|
|
230
|
+
* Neon date search fields filter at day granularity; a date-only `>=` is a safe, slightly
|
|
231
|
+
* conservative bound that never drops a same-day record (the engine still re-narrows via
|
|
232
|
+
* content hash). Returns undefined for an unparseable watermark.
|
|
233
|
+
*/
|
|
234
|
+
private formatNeonSearchDate;
|
|
235
|
+
/** Reads the Neon pagination envelope to decide whether another door page remains (0-based). */
|
|
236
|
+
private hasMorePages;
|
|
237
|
+
/** Returns the declared PK field names (by Sequence) or ['ID'] fallback — mirrors the base helper. */
|
|
238
|
+
private findPKFieldNames;
|
|
239
|
+
/**
|
|
240
|
+
* Builds an ExternalRecord with the SAME identity semantics the base uses: declared PK when every
|
|
241
|
+
* component is present + non-empty, else a deterministic content hash (the synthetic-PK fallback for
|
|
242
|
+
* PK-less / partial-key leaves). The FULL record passes through in `Fields` (full-record pass-through).
|
|
243
|
+
*/
|
|
244
|
+
private buildExternalRecord;
|
|
245
|
+
/** A zero-record batch carrying a structured FetchWarning so the silent-empty is surfaced. */
|
|
246
|
+
private zeroWithWarning;
|
|
247
|
+
/**
|
|
248
|
+
* Parses the connection config, preferring the attached MJ Credential over the raw
|
|
249
|
+
* Configuration JSON. Credential bytes are resolved at runtime — never at build.
|
|
250
|
+
*/
|
|
251
|
+
private ParseConfig;
|
|
252
|
+
/** Loads the config from the MJ: Credentials entity Values JSON. */
|
|
253
|
+
private parseConfigFromCredential;
|
|
254
|
+
/** Validates the parsed config + applies defaults. Field names are case-insensitive. */
|
|
255
|
+
private validateConfig;
|
|
256
|
+
/** Extracts the latest timestamps.lastModifiedDateTime across a batch for watermark advancement. */
|
|
257
|
+
private extractLatestModifiedDate;
|
|
258
|
+
/** Reads timestamps.lastModifiedDateTime (Neon's nested cursor) or a flat top-level fallback. */
|
|
259
|
+
private readLastModified;
|
|
260
|
+
/** Parses a Retry-After header (seconds or http-date) into ms, if present. */
|
|
261
|
+
private retryAfterMs;
|
|
262
|
+
/** Exponential backoff delay for retry attempts (capped at 30s). */
|
|
263
|
+
private backoffDelay;
|
|
264
|
+
/** Checks whether an error is transient (network/timeout). */
|
|
265
|
+
private isTransientNetworkError;
|
|
266
|
+
/** Builds the normalized RESTResponse from a fetch Response. */
|
|
267
|
+
private buildRESTResponse;
|
|
268
|
+
/** Promise-wrapped setTimeout. */
|
|
269
|
+
private sleep;
|
|
270
|
+
}
|
|
271
|
+
/** Tree-shaking prevention — import and call from the package entry point. */
|
|
272
|
+
export declare function LoadNeonCRMConnector(): void;
|