@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.
@@ -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;