@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
@@ -4,220 +4,289 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
+ /**
8
+ * GrowthZoneConnector — Integration connector for the GrowthZone (MicroNet Online, Inc.)
9
+ * association-management REST API.
10
+ *
11
+ * API docs: https://documentation.growthzoneapp.com/ (Curated + dev SpectaQL pages)
12
+ *
13
+ * ── Auth: OAuth2 Bearer (PRIMARY + REQUIRED) ────────────────────────────────
14
+ * Mints/refreshes a bearer ACCESS TOKEN via the shared {@link OAuth2TokenManager}
15
+ * (no inlined token/crypto logic):
16
+ * - PRIMARY grant = `refresh_token` (client_id + client_secret + refresh_token
17
+ * POSTed to `{base}/oauth/token`, or the credential's TokenURL).
18
+ * - FALLBACK grant = `password` (username + password + scopes) when the credential
19
+ * supplies no refresh token.
20
+ * Every request sends `Authorization: Bearer {accessToken}`.
21
+ *
22
+ * An `ApiKey` header is recorded in the integration Configuration ONLY as a
23
+ * documented, DEPRECATED alternate — it is NOT the primary path and is not wired
24
+ * here (see the deprecated-alternate note in {@link BuildHeaders}). The prior
25
+ * ApiKey-primary connector this class WHOLESALE REPLACES is gone.
26
+ *
27
+ * ── Base URL ────────────────────────────────────────────────────────────────
28
+ * Comes from the credential's `BaseURL` (the operator's GrowthZone API endpoint,
29
+ * e.g. `https://{subdomain}.growthzoneapp.com/API`) — never a hardcoded subdomain.
30
+ *
31
+ * ── Catalog (metadata-driven, NOT hardcoded) ───────────────────────────────
32
+ * Objects/fields come from the Declared metadata seeded in
33
+ * `metadata/integrations/growthzone/.growthzone.integration.json` and loaded into the
34
+ * IntegrationEngineBase cache. The connector NEVER bakes an object/field catalog into
35
+ * code (the old `GROWTHZONE_ACTION_OBJECTS` of 7 streams is removed); discovery and
36
+ * action-object surfacing read the full ~38-IO universe straight from the cache.
37
+ *
38
+ * ── Nested access paths (Door → Segments) ──────────────────────────────────
39
+ * Nested objects (e.g. `Person` under `Contact`, `EventAttendee` under `Event`) are
40
+ * reached by template-variable APIPaths (`/api/contacts/person/{contactId}`); the base
41
+ * class's FetchChanges walks the door→segment path by resolving each `{var}` to its
42
+ * parent IO via the FK metadata and substituting synced parent IDs — so no nested IO
43
+ * ships as a silent 0-row table. The connector supplies only auth, headers, pagination,
44
+ * and normalization.
45
+ *
46
+ * ── Pagination & incremental ───────────────────────────────────────────────
47
+ * OData `skip`/`top` on flat list endpoints. Incremental sync is fully metadata-driven:
48
+ * an IO with `SupportsIncrementalSync=true` emits its `IncrementalWatermarkField` param
49
+ * (e.g. the Contact object at `/api/contacts` uses `modifiedSince`); every other IO is a
50
+ * full pull (content-hash dedup handled by the engine).
51
+ *
52
+ * ── Write ───────────────────────────────────────────────────────────────────
53
+ * The curated AMS surface is read-only (Pull). SupportsWrite=false; no CRUD wired.
54
+ */
7
55
  import { RegisterClass } from '@memberjunction/global';
8
56
  import { Metadata } from '@memberjunction/core';
9
- import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
57
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, OAuth2TokenManager, } from '@memberjunction/integration-engine';
58
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
10
59
  // ─── Constants ───────────────────────────────────────────────────────
11
- /** Default maximum retries */
60
+ /** The canonical MJ: Integrations.Name — part of the three-way invariant. */
61
+ const INTEGRATION_NAME = 'GrowthZone';
62
+ /** Token endpoint path appended to the base when no TokenURL override is supplied. */
63
+ const DEFAULT_TOKEN_PATH = '/oauth/token';
64
+ /**
65
+ * OData pagination param names. GrowthZone is an OData-style API and honors the STANDARD `$`-prefixed
66
+ * params (`$top`/`$skip`); the un-prefixed `top`/`skip` are SILENTLY IGNORED by the server — it returns
67
+ * the same first page for any `skip` value, which the base pagination loop's duplicate-page detector
68
+ * then (correctly) treats as end-of-stream, capping every object at one server-default page (~100).
69
+ * Verified live: `?skip=3` returns the same first IDs as `?skip=0`, while `?$skip=3` advances.
70
+ */
71
+ const ODATA_SKIP_PARAM = '$skip';
72
+ const ODATA_TOP_PARAM = '$top';
12
73
  const DEFAULT_MAX_RETRIES = 3;
13
- /** Default HTTP timeout (ms) */
14
74
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
15
- /** Default minimum interval between requests (ms) — GrowthZone recommends 2 seconds */
16
75
  const DEFAULT_MIN_REQUEST_INTERVAL_MS = 2_000;
17
- /** Default page size for flat list endpoints */
18
76
  const DEFAULT_PAGE_SIZE = 500;
19
- /** Sentinel date used by GrowthZone to represent null datetimes */
20
- const GROWTHZONE_NULL_DATE = '0001-01-01T00:00:00';
21
- /** Names of nested array fields to flatten from OrgGeneral */
22
- const ORG_NESTED_ARRAYS = [
23
- 'Activities',
24
- 'Categories',
25
- 'Certifications',
26
- 'Communication',
27
- 'Memberships',
28
- 'SalesOpportunities',
29
- ];
30
- // ─── Action-metadata objects (mirrors .growthzone.json) ──────────────
31
- const GROWTHZONE_ACTION_OBJECTS = [
32
- {
33
- Name: 'contacts', DisplayName: 'Contact',
34
- Description: 'A contact (person or organization) in GrowthZone',
35
- SupportsWrite: false,
36
- Fields: [
37
- { Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'GrowthZone contact identifier' },
38
- { Name: 'DisplayName', DisplayName: 'Display Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Formatted display name' },
39
- { Name: 'ModifiedDate', DisplayName: 'Modified Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last modification timestamp' },
40
- { Name: 'IsDeleted', DisplayName: 'Is Deleted', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Soft-delete flag' },
41
- { Name: 'SystemContactTypeId', DisplayName: 'Contact Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Individual (1) or Organization (2)' },
42
- ],
43
- },
44
- {
45
- Name: 'memberships', DisplayName: 'Membership',
46
- Description: 'A membership held by a contact in GrowthZone',
47
- SupportsWrite: false,
48
- Fields: [
49
- { Name: 'MembershipId', DisplayName: 'Membership ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Membership identifier' },
50
- { Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Holder contact' },
51
- { Name: 'MembershipTypeId', DisplayName: 'Membership Type ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Membership type/package' },
52
- { Name: 'MembershipTypeName', DisplayName: 'Membership Type Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Type name' },
53
- { Name: 'MembershipStatusTypeId', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Status code (0-9)' },
54
- { Name: 'StartDate', DisplayName: 'Start Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Effective start date' },
55
- { Name: 'EndDate', DisplayName: 'End Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Expiration date' },
56
- ],
57
- },
58
- {
59
- Name: 'groups', DisplayName: 'Group',
60
- Description: 'A group/committee in GrowthZone', SupportsWrite: false,
61
- Fields: [
62
- { Name: 'GroupId', DisplayName: 'Group ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Group identifier' },
63
- { Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Group name' },
64
- { Name: 'Description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Group description' },
65
- { Name: 'IsActive', DisplayName: 'Is Active', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Active flag' },
66
- ],
67
- },
68
- {
69
- Name: 'events', DisplayName: 'Event',
70
- Description: 'An event in GrowthZone', SupportsWrite: false,
71
- Fields: [
72
- { Name: 'EventId', DisplayName: 'Event ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Event identifier' },
73
- { Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event name' },
74
- { Name: 'StartDate', DisplayName: 'Start Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Start datetime' },
75
- { Name: 'EndDate', DisplayName: 'End Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'End datetime' },
76
- { Name: 'Location', DisplayName: 'Location', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event location' },
77
- { Name: 'IsActive', DisplayName: 'Is Active', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Active flag' },
78
- ],
79
- },
80
- {
81
- Name: 'invoices', DisplayName: 'Invoice',
82
- Description: 'An invoice in GrowthZone billing', SupportsWrite: false,
83
- Fields: [
84
- { Name: 'InvoiceId', DisplayName: 'Invoice ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Invoice identifier' },
85
- { Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Billed contact' },
86
- { Name: 'InvoiceNumber', DisplayName: 'Invoice Number', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User-visible invoice number' },
87
- { Name: 'TotalAmount', DisplayName: 'Total Amount', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Invoice total' },
88
- { Name: 'BalanceDue', DisplayName: 'Balance Due', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Outstanding balance' },
89
- { Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Invoice status' },
90
- ],
91
- },
92
- {
93
- Name: 'payments', DisplayName: 'Payment',
94
- Description: 'A payment record in GrowthZone', SupportsWrite: false,
95
- Fields: [
96
- { Name: 'PaymentId', DisplayName: 'Payment ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Payment identifier' },
97
- { Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Paying contact' },
98
- { Name: 'Amount', DisplayName: 'Amount', Type: 'decimal', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Payment amount' },
99
- { Name: 'PaymentMethod', DisplayName: 'Payment Method', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Method (Card, ACH, Check, Cash)' },
100
- { Name: 'PaymentStatus', DisplayName: 'Payment Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Status (Pending, Captured, Refunded)' },
101
- ],
102
- },
103
- {
104
- Name: 'certifications', DisplayName: 'Certification',
105
- Description: 'A certification held by a contact', SupportsWrite: false,
106
- Fields: [
107
- { Name: 'CertificationContactId', DisplayName: 'Certification Contact ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Unique record identifier' },
108
- { Name: 'ContactId', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Holder contact' },
109
- { Name: 'CertificationId', DisplayName: 'Certification ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Certification definition' },
110
- { Name: 'Name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Certification name' },
111
- { Name: 'Status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Status (InProgress, Completed, Expired)' },
112
- ],
113
- },
114
- ];
115
- // ─── Connector Implementation ────────────────────────────────────────
116
77
  /**
117
- * Connector for the GrowthZone association management REST API.
118
- *
119
- * Extends BaseRESTIntegrationConnector to leverage metadata-driven object/field
120
- * discovery from IntegrationEngineBase cache plus generic pagination.
121
- *
122
- * Auth: sends `Authorization: ApiKey {key}` on every request. API keys are
123
- * provisioned by GrowthZone WebSupport. Tenant subdomain is required to
124
- * resolve the base URL.
125
- *
126
- * Pagination: GrowthZone uses OData-style params on flat endpoints —
127
- * `$skip`, `$top`, and `$orderby`. `$filter`, `$expand`, `$select` are NOT
128
- * supported. The only incremental endpoint is `/api/contacts/delta`, which
129
- * accepts `?modifiedSince=ISO8601&top=N`.
130
- *
131
- * Throttle: GrowthZone recommends 2s minimum interval; the connector defaults
132
- * to that. Responds to HTTP 429/503 with exponential backoff.
78
+ * GrowthZone hard-caps an OData list response at 100 rows regardless of the requested `top`,
79
+ * and several list endpoints reject a `top` above that cap with `400 The request is invalid`.
80
+ * Clamping the requested page size to the real server cap makes `skip` advance in lockstep with
81
+ * the rows actually returned (no gaps/overlap) and keeps every list request inside the server's
82
+ * accepted range. Not vendor-guesswork: it's GrowthZone's documented/observed page ceiling.
133
83
  */
84
+ const GROWTHZONE_MAX_PAGE_SIZE = 100;
85
+ /** GrowthZone sentinel value for a null datetime. */
86
+ const GROWTHZONE_NULL_DATE = '0001-01-01T00:00:00';
87
+ // ─── Connector Implementation ────────────────────────────────────────
134
88
  let GrowthZoneConnector = class GrowthZoneConnector extends BaseRESTIntegrationConnector {
135
89
  constructor() {
136
90
  super(...arguments);
137
- /** Cached auth context for the current sync run */
91
+ /** Cached auth context for the current sync run. */
138
92
  this.authCache = null;
139
- /** Timestamp of the last API request, for throttling */
93
+ /** Shared OAuth2 token manager — owns the token round-trip + cache. */
94
+ this.tokenManager = new OAuth2TokenManager();
95
+ /** Timestamp of the last API request, for client-side throttling. */
140
96
  this.lastRequestTime = 0;
141
- /**
142
- * Object name currently being fetched — tracked so BuildPaginatedURL can
143
- * emit endpoint-specific query parameters (e.g., `modifiedSince` for delta).
144
- */
145
- this.currentObjectName = '';
146
97
  }
98
+ // Returns the EXACT MJ: Integrations.Name string LITERAL (not the INTEGRATION_NAME const) so the
99
+ // T1 ThreeWayName invariant can statically parse the getter's returned value from connector source.
147
100
  get IntegrationName() { return 'GrowthZone'; }
101
+ // ── Capability getters: METADATA-DRIVEN (no hardcoded answer) ─────
102
+ //
103
+ // Write capability FOLLOWS the per-operation CRUD columns on the cached IntegrationObjects
104
+ // (Declared metadata), exactly like GetIntegrationObjects surfaces the object universe from
105
+ // metadata rather than a baked catalog. An object is create-capable when it declares both
106
+ // CreateAPIPath + CreateMethod; same for update/delete. With no write metadata authored the
107
+ // surface is read-only (returns false) — but the moment a writable object's per-operation
108
+ // columns are populated, the capability flips on, and the base BaseRESTIntegrationConnector
109
+ // generic CRUD path executes it. (NB: GrowthZone's writes are largely wizard/operation-based,
110
+ // so an object whose write does NOT fit the generic flat/wrapped body must additionally
111
+ // override CreateRecord/UpdateRecord/DeleteRecord — the metadata flag alone is not enough for
112
+ // those. See PROBLEMS_LOG #30/#35 for the per-object wizard-write roadmap.)
113
+ get SupportsCreate() {
114
+ return this.anyObjectDeclares(o => !!o.CreateAPIPath && !!o.CreateMethod);
115
+ }
116
+ get SupportsUpdate() {
117
+ return this.anyObjectDeclares(o => !!o.UpdateAPIPath && !!o.UpdateMethod);
118
+ }
119
+ get SupportsDelete() {
120
+ return this.anyObjectDeclares(o => !!o.DeleteAPIPath && !!o.DeleteMethod);
121
+ }
122
+ /** True when any cached IntegrationObject satisfies the predicate. []→false when the engine
123
+ * cache is unavailable (e.g. capability probed before configuration) — fail-safe read-only. */
124
+ anyObjectDeclares(pred) {
125
+ const integration = IntegrationEngineBase.Instance.GetIntegrationByName(INTEGRATION_NAME);
126
+ if (!integration)
127
+ return false;
128
+ return IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integration.ID).some(pred);
129
+ }
130
+ // ── Write: GrowthZone audit-token DELETE idiom (override) ─────────
131
+ //
132
+ // GrowthZone splits deletes into two shapes:
133
+ // • single-ID `DELETE /api/store/storeitems/{id}`, `/directory/directories/{id}`,
134
+ // `/memberships/benefititem/{id}`, `/signatures/{id}`, `/webhooks/setup/{id}` →
135
+ // ride the BASE generic DeleteRecord ({id} substitution) unchanged.
136
+ // • audit-token `DELETE /api/calendars/{id}/{auditid}`, `/directorylistingtypes/{id}/{auditid}`,
137
+ // `/roles/{id}/{auditid}` → need the record's CURRENT optimistic-concurrency audit
138
+ // token, which DeleteRecordContext (ExternalID only) doesn't carry. We fetch the
139
+ // record, read its audit token, and substitute BOTH path vars.
140
+ // Doc-derived shape; the live byte-shape is unverified per the no-live-writes rule (PROBLEMS_LOG #35).
141
+ async DeleteRecord(ctx) {
142
+ const ci = ctx.CompanyIntegration;
143
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
144
+ // Not an audit-token path → the base generic single-{id} delete handles it.
145
+ if (!obj.DeleteAPIPath || !/\{audit/i.test(obj.DeleteAPIPath)) {
146
+ return super.DeleteRecord(ctx);
147
+ }
148
+ const auditToken = await this.ResolveAuditToken(ctx);
149
+ if (auditToken == null) {
150
+ return {
151
+ Success: false,
152
+ StatusCode: 0,
153
+ ErrorMessage: `DeleteRecord("${ctx.ObjectName}"): ${obj.DeleteAPIPath} requires a GrowthZone audit ` +
154
+ `token, but none was resolvable for ExternalID "${ctx.ExternalID}".`,
155
+ };
156
+ }
157
+ const auth = await this.Authenticate(ci, ctx.ContextUser);
158
+ // Resource id → the FIRST {…id} var; audit token → the {audit…} var.
159
+ const path = obj.DeleteAPIPath
160
+ .replace(/\{[A-Za-z]*[iI]d\}/, encodeURIComponent(ctx.ExternalID))
161
+ .replace(/\{audit[A-Za-z]*\}/i, encodeURIComponent(auditToken));
162
+ const baseURL = this.GetBaseURL(ci, auth);
163
+ const url = `${baseURL.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
164
+ const response = await this.MakeHTTPRequest(auth, url, obj.DeleteMethod ?? 'DELETE', this.BuildHeaders(auth));
165
+ if (response.Status >= 200 && response.Status < 300) {
166
+ return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
167
+ }
168
+ return {
169
+ Success: false,
170
+ StatusCode: response.Status,
171
+ ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on delete`,
172
+ };
173
+ }
174
+ /**
175
+ * Resolves a record's current GrowthZone audit token (optimistic-concurrency) for an audit-token
176
+ * delete. GrowthZone exposes it as `AuditId` / `{Object}AuditId` on the record; we GetRecord and
177
+ * return the first match. Returns null when the record or token cannot be found.
178
+ */
179
+ async ResolveAuditToken(ctx) {
180
+ const rec = await this.GetRecord({
181
+ CompanyIntegration: ctx.CompanyIntegration,
182
+ ObjectName: ctx.ObjectName,
183
+ ContextUser: ctx.ContextUser,
184
+ ExternalID: ctx.ExternalID,
185
+ });
186
+ if (!rec)
187
+ return null;
188
+ for (const k of ['AuditId', 'auditId', 'AuditID', `${ctx.ObjectName}AuditId`]) {
189
+ const v = rec.Fields[k];
190
+ if (v != null && String(v).length > 0)
191
+ return String(v);
192
+ }
193
+ return null;
194
+ }
195
+ // ── Action-object surfacing: METADATA-DRIVEN (no hardcoded catalog) ──
196
+ /**
197
+ * Surfaces the FULL object universe straight from the IntegrationEngineBase
198
+ * cache (the Declared metadata in `.growthzone.integration.json`). There is NO
199
+ * hardcoded catalog in this connector — the prohibited module-level object/field
200
+ * literal is intentionally absent. When the cache is unavailable (e.g. action
201
+ * generation invoked before the engine is configured) this returns []; the live
202
+ * discovery path (DiscoverObjects/DiscoverFields) is the authoritative surface.
203
+ */
148
204
  GetIntegrationObjects() {
149
- return GROWTHZONE_ACTION_OBJECTS;
205
+ const integration = IntegrationEngineBase.Instance.GetIntegrationByName(INTEGRATION_NAME);
206
+ if (!integration)
207
+ return [];
208
+ const objects = IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integration.ID);
209
+ return objects.map(obj => this.ObjectEntityToInfo(obj));
150
210
  }
151
211
  GetActionGeneratorConfig() {
152
212
  const objects = this.GetIntegrationObjects();
153
213
  if (objects.length === 0)
154
214
  return null;
155
215
  return {
156
- IntegrationName: 'GrowthZone',
157
- CategoryName: 'GrowthZone',
216
+ IntegrationName: INTEGRATION_NAME,
217
+ CategoryName: INTEGRATION_NAME,
158
218
  IconClass: 'fa-solid fa-seedling',
159
219
  Objects: objects,
160
220
  IncludeSearch: false,
161
221
  IncludeList: false,
162
- CategoryDescription: 'GrowthZone association management integration for contacts, memberships, events, and billing',
222
+ CategoryDescription: 'GrowthZone association management — contacts, memberships, groups, certifications, events, store, billing, and directories',
163
223
  ParentCategoryName: 'Association Management',
164
224
  };
165
225
  }
166
- // ── Capability getters ───────────────────────────────────────────
167
- get SupportsCreate() { return false; }
168
- get SupportsUpdate() { return false; }
169
- get SupportsDelete() { return false; }
170
- // ── Default mappings ─────────────────────────────────────────────
171
- GetDefaultConfiguration() {
226
+ /** Maps a cached IntegrationObject + its fields to the action-generator info shape. */
227
+ ObjectEntityToInfo(obj) {
228
+ const fields = IntegrationEngineBase.Instance.GetIntegrationObjectFields(obj.ID)
229
+ .filter(f => f.Status === 'Active')
230
+ .sort((a, b) => a.Sequence - b.Sequence);
231
+ const fieldInfos = fields.map(f => ({
232
+ Name: f.Name,
233
+ DisplayName: f.DisplayName ?? f.Name,
234
+ Type: (f.Type ?? 'string').toLowerCase(),
235
+ IsRequired: f.IsRequired ?? false,
236
+ IsReadOnly: f.IsReadOnly ?? true,
237
+ IsPrimaryKey: f.IsPrimaryKey ?? false,
238
+ Description: f.Description ?? undefined,
239
+ }));
172
240
  return {
173
- DefaultSchemaName: 'GrowthZone',
174
- DefaultObjects: [],
241
+ Name: obj.Name,
242
+ DisplayName: obj.DisplayName ?? obj.Name,
243
+ Description: obj.Description ?? undefined,
244
+ SupportsWrite: obj.SupportsWrite ?? false,
245
+ Fields: fieldInfos,
175
246
  };
176
247
  }
177
- GetDefaultFieldMappings(objectName, _entityName) {
178
- const lower = objectName.toLowerCase();
179
- if (lower === 'contacts' || lower === 'contact-person') {
180
- return [
181
- { SourceFieldName: 'ContactId', DestinationFieldName: 'ExternalID', IsKeyField: true },
182
- { SourceFieldName: 'DisplayName', DestinationFieldName: 'Name' },
183
- { SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
184
- { SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
185
- { SourceFieldName: 'IsDeleted', DestinationFieldName: 'IsDeleted' },
186
- { SourceFieldName: 'ModifiedDate', DestinationFieldName: 'UpdatedAt' },
187
- ];
188
- }
189
- if (lower === 'contact-org') {
190
- return [
191
- { SourceFieldName: 'ContactId', DestinationFieldName: 'ExternalID', IsKeyField: true },
192
- { SourceFieldName: 'OrganizationName', DestinationFieldName: 'Name' },
193
- { SourceFieldName: 'ModifiedDate', DestinationFieldName: 'UpdatedAt' },
194
- ];
195
- }
196
- if (lower === 'memberships') {
197
- return [
198
- { SourceFieldName: 'MembershipId', DestinationFieldName: 'ExternalID', IsKeyField: true },
199
- { SourceFieldName: 'ContactId', DestinationFieldName: 'ContactExternalID' },
200
- { SourceFieldName: 'MembershipStatusTypeId', DestinationFieldName: 'Status' },
201
- { SourceFieldName: 'StartDate', DestinationFieldName: 'StartDate' },
202
- { SourceFieldName: 'EndDate', DestinationFieldName: 'EndDate' },
203
- ];
204
- }
205
- return [];
206
- }
207
248
  // ─── BaseRESTIntegrationConnector abstract methods ──────────────
249
+ /**
250
+ * OAuth2 bearer authentication. Mints/refreshes the access token via the shared
251
+ * {@link OAuth2TokenManager}: PRIMARY `refresh_token` grant when a refresh token is
252
+ * present, otherwise the documented FALLBACK `password` grant.
253
+ */
208
254
  async Authenticate(companyIntegration, contextUser) {
209
255
  if (this.authCache)
210
256
  return this.authCache;
211
257
  const config = await this.ParseConfig(companyIntegration, contextUser);
212
258
  const baseUrl = this.ResolveBaseUrl(config);
213
- const auth = { Token: config.ApiKey, BaseUrl: baseUrl, Config: config };
259
+ const token = await this.MintToken(config, baseUrl);
260
+ const auth = { Token: token, BaseUrl: baseUrl, Config: config };
214
261
  this.authCache = auth;
215
262
  return auth;
216
263
  }
264
+ /** Selects the grant and runs the token round-trip through OAuth2TokenManager. */
265
+ async MintToken(config, baseUrl) {
266
+ const tokenURL = this.ResolveTokenURL(config, baseUrl);
267
+ const grant = config.RefreshToken ? 'refresh_token' : 'password';
268
+ const req = {
269
+ TokenURL: tokenURL,
270
+ ClientId: config.ClientId,
271
+ ClientSecret: config.ClientSecret,
272
+ RefreshToken: config.RefreshToken,
273
+ Username: config.Username,
274
+ Password: config.Password,
275
+ Scopes: config.Scopes,
276
+ ScopeParam: 'scopes',
277
+ TimeoutMs: config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
278
+ };
279
+ const token = await this.tokenManager.GetAccessToken(req, grant);
280
+ return token.AccessToken;
281
+ }
282
+ /** Sends the OAuth2 bearer token on every request. */
217
283
  BuildHeaders(auth) {
218
- const token = auth.Token ?? '';
284
+ // NOTE: GrowthZone documents an `Authorization: ApiKey {key}` alternate, but it is
285
+ // DEPRECATED-not-used per the integration Configuration. OAuth2 bearer is the only
286
+ // wired path; do not reintroduce an ApiKey branch as the primary.
287
+ const token = auth.Token ?? auth.Token ?? '';
219
288
  return {
220
- 'Authorization': `ApiKey ${token}`,
289
+ 'Authorization': `Bearer ${token}`,
221
290
  'Accept': 'application/json',
222
291
  'Content-Type': 'application/json',
223
292
  };
@@ -226,12 +295,27 @@ let GrowthZoneConnector = class GrowthZoneConnector extends BaseRESTIntegrationC
226
295
  return auth.BaseUrl;
227
296
  }
228
297
  /**
229
- * Extracts records from GrowthZone responses. Handles three shapes:
298
+ * Normalizes GrowthZone responses. Handles three real shapes:
230
299
  * 1. `{ Results: [...], TotalRecordAvailable: N }` — paginated lists
231
- * 2. Raw array — some endpoints return arrays at the root
232
- * 3. Single object — detail endpoints (per-contact fetches)
300
+ * 2. raw array — some endpoints return an array at the root
301
+ * 3. single object — genuine per-parent DETAIL endpoints (Person, Organization,
302
+ * ContactCustomField, ContactNotes, ContactEngagement, ScheduledBillingUpdate,
303
+ * MembershipChange) return ONE real record per parent. These ARE kept.
304
+ * and coerces `0001-01-01` sentinel dates / empty strings to null.
233
305
  *
234
- * Normalizes `0001-01-01` dates and empty strings to null.
306
+ * THE EMPTY-EVENT-CHILD SENTINEL (PROBLEMS_LOG #22/#23/#27 — idempotency, surgically scoped):
307
+ * The per-event child list endpoints (`/api/events/sponsors?eventId=X`, …/sessions, …/attendees,
308
+ * etc.) do NOT return `{ Results: [] }` when an event has no children — they return the bare
309
+ * EVENT-DETAIL wrapper instead. That wrapper carries volatile per-fetch audit fields
310
+ * (EventAuditId / EventDetailAuditId), so minting a record from it produced ONE empty placeholder
311
+ * per event whose §4 content-hash identity drifted every sync → child tables doubled (254 = 2×127,
312
+ * all rows empty). Fix: detect the event wrapper (it uniquely carries EventAuditId+EventDetailAuditId
313
+ * — a genuine child/detail record never does) and emit []. This is the ONLY single-object case we
314
+ * drop; every legitimate single-record detail endpoint is preserved (the #27 regression guard).
315
+ * Populated children still arrive as a `Results` envelope and map normally.
316
+ *
317
+ * The FULL source record passes through (no field filtering) so the framework's custom-column
318
+ * capture sees everything the source returned.
235
319
  */
236
320
  NormalizeResponse(rawBody, responseDataKey) {
237
321
  if (rawBody == null)
@@ -241,84 +325,88 @@ let GrowthZoneConnector = class GrowthZoneConnector extends BaseRESTIntegrationC
241
325
  }
242
326
  if (typeof rawBody === 'object') {
243
327
  const body = rawBody;
244
- // Paginated envelope: { Results: [...], TotalRecordAvailable: N }
245
328
  const envelope = body;
246
329
  if (Array.isArray(envelope.Results)) {
247
330
  return envelope.Results.map(r => this.NormalizeRecord(r));
248
331
  }
249
- // Custom response key from metadata
250
332
  if (responseDataKey && Array.isArray(body[responseDataKey])) {
251
- const arr = body[responseDataKey];
252
- return arr.map(r => this.NormalizeRecord(r));
333
+ return body[responseDataKey].map(r => this.NormalizeRecord(r));
253
334
  }
254
- // Nested flattening cases for OrgGeneral/NotesAndFields
255
- const flattened = this.MaybeFlattenNested(body);
256
- if (flattened)
257
- return flattened;
258
- // Single detail record
335
+ // Empty-event-child sentinel: an event-child endpoint returned the bare EVENT wrapper
336
+ // (no children for this event). Emit nothing — never a non-idempotent empty placeholder.
337
+ if (this.isEmptyEventChildWrapper(body))
338
+ return [];
339
+ // Genuine single-object detail record (Person / Organization / ContactCustomField / …).
259
340
  return [this.NormalizeRecord(body)];
260
341
  }
261
342
  return [];
262
343
  }
263
344
  /**
264
- * Extracts pagination state. GrowthZone uses OData pagination with
265
- * `$skip` and `$top`; there's no `HasMore` flag — we derive it from
266
- * `TotalRecordAvailable` vs. current offset + returned count.
345
+ * True when a single-object response is the GrowthZone EVENT-DETAIL wrapper — the body the
346
+ * per-event child endpoints return when an event has zero children (see NormalizeResponse).
347
+ * The wrapper is unmistakable: it carries BOTH event-audit stamps. No genuine child/detail
348
+ * record (Person, Organization, custom fields, notes, engagement) carries these keys, so this
349
+ * stays surgically scoped to the empty-event-child case and never drops a real record.
350
+ */
351
+ isEmptyEventChildWrapper(body) {
352
+ return 'EventAuditId' in body && 'EventDetailAuditId' in body;
353
+ }
354
+ /**
355
+ * Derives OData pagination state. GrowthZone returns no `HasMore` flag, so end-of-stream
356
+ * is inferred from a short page and/or `TotalRecordAvailable` vs offset+count.
267
357
  */
268
358
  ExtractPaginationInfo(rawBody, _paginationType, _currentPage, currentOffset, pageSize) {
269
359
  if (!rawBody || typeof rawBody !== 'object') {
270
360
  return { HasMore: false };
271
361
  }
272
362
  const body = rawBody;
273
- const results = Array.isArray(body.Results) ? body.Results : [];
274
- const total = typeof body.TotalRecordAvailable === 'number'
275
- ? body.TotalRecordAvailable
276
- : undefined;
277
- // Fewer records than requested = end of stream
278
- if (results.length < pageSize) {
279
- return { HasMore: false, TotalRecords: total };
280
- }
281
- // Check against total count if provided
363
+ const results = Array.isArray(body.Results)
364
+ ? body.Results
365
+ : (Array.isArray(rawBody) ? rawBody : []);
366
+ const total = typeof body.TotalRecordAvailable === 'number' ? body.TotalRecordAvailable : undefined;
282
367
  const nextOffset = currentOffset + results.length;
283
- if (total !== undefined && nextOffset >= total) {
284
- return { HasMore: false, TotalRecords: total };
368
+ // AUTHORITATIVE: when GrowthZone reports the total, trust it over any page-size heuristic.
369
+ // The server caps a page at GROWTHZONE_MAX_PAGE_SIZE (100) even when we request more, so a
370
+ // "short" page (results.length < requested pageSize) is NOT a reliable end-of-stream signal —
371
+ // it's just the server cap. Comparing offset+count to the total is the only correct terminator.
372
+ if (total !== undefined) {
373
+ return nextOffset >= total
374
+ ? { HasMore: false, TotalRecords: total }
375
+ : { HasMore: true, NextOffset: nextOffset, TotalRecords: total };
376
+ }
377
+ // No total provided: the only reliable terminator is an EMPTY page. A non-empty short page may
378
+ // simply be the server cap, so we keep paging until the server returns zero rows.
379
+ void pageSize; // intentionally not used as a terminator (see above)
380
+ if (results.length === 0) {
381
+ return { HasMore: false };
285
382
  }
286
- return {
287
- HasMore: true,
288
- NextOffset: nextOffset,
289
- TotalRecords: total,
290
- };
383
+ return { HasMore: true, NextOffset: nextOffset };
291
384
  }
292
385
  /**
293
- * Overrides the base pagination URL builder to emit GrowthZone-specific
294
- * OData parameters (`$skip`, `$top`) and, for the delta contacts endpoint,
295
- * the `modifiedSince` watermark parameter.
296
- *
297
- * Driven entirely by IntegrationObject metadata — no hardcoded object-name
298
- * switches beyond the delta-specific param which is keyed on APIPath.
386
+ * Emits GrowthZone OData params (`skip`/`top`) plus, for an incremental IO, the vendor
387
+ * watermark param. The watermark behaviour is fully METADATA-DRIVEN: the param NAME comes
388
+ * from the IO's `IncrementalWatermarkField` (e.g. `modifiedSince` on the Contact object) and
389
+ * is emitted only when `SupportsIncrementalSync=true` AND a watermark value is in context —
390
+ * never keyed off a hardcoded path suffix or object name.
299
391
  */
300
392
  BuildPaginatedURL(basePath, obj, _page, offset, _cursor, effectivePageSize) {
301
- const limit = effectivePageSize ?? obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
393
+ const requested = effectivePageSize ?? obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
394
+ // Clamp to GrowthZone's server-side page ceiling — a larger `top` is silently capped at 100
395
+ // (truncating pagination) and on some endpoints is rejected outright with HTTP 400.
396
+ const limit = Math.min(requested, GROWTHZONE_MAX_PAGE_SIZE);
397
+ const separator = basePath.includes('?') ? '&' : '?';
302
398
  const params = new URLSearchParams();
303
- // GrowthZone's only incremental endpoint: /api/contacts/delta
304
- const isDelta = obj.APIPath?.toLowerCase().endsWith('/delta') ?? false;
305
- if (isDelta && this.currentWatermark) {
306
- params.set('modifiedSince', this.currentWatermark);
307
- params.set('top', String(limit));
308
- if (offset > 0)
309
- params.set('skip', String(offset));
310
- }
311
- else if (obj.SupportsPagination && obj.PaginationType === 'Offset') {
312
- params.set('$top', String(limit));
313
- if (offset > 0)
314
- params.set('$skip', String(offset));
399
+ const watermarkField = obj.IncrementalWatermarkField;
400
+ if (obj.SupportsIncrementalSync && watermarkField && this.currentWatermark) {
401
+ params.set(watermarkField, this.currentWatermark);
315
402
  }
403
+ params.set(ODATA_TOP_PARAM, String(limit));
404
+ if (offset > 0)
405
+ params.set(ODATA_SKIP_PARAM, String(offset));
316
406
  const qs = params.toString();
317
- return qs ? `${basePath}?${qs}` : basePath;
407
+ return qs ? `${basePath}${separator}${qs}` : basePath;
318
408
  }
319
- /**
320
- * Executes an HTTP request with rate limiting and retry for 429/503.
321
- */
409
+ /** Executes an HTTP request with client-side throttling + retry for 429/503. */
322
410
  async MakeHTTPRequest(auth, url, method, headers, body) {
323
411
  const gzAuth = auth;
324
412
  const maxRetries = gzAuth.Config.MaxRetries ?? DEFAULT_MAX_RETRIES;
@@ -348,7 +436,7 @@ let GrowthZoneConnector = class GrowthZoneConnector extends BaseRESTIntegrationC
348
436
  this.lastRequestTime = Date.now();
349
437
  if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
350
438
  console.warn(`[GrowthZone] HTTP ${response.status} from ${url} — backing off`);
351
- await this.Sleep(this.BackoffDelay(attempt));
439
+ await this.Sleep(this.RetryAfterMs(response) ?? this.BackoffDelay(attempt));
352
440
  continue;
353
441
  }
354
442
  return this.BuildRESTResponse(response);
@@ -357,34 +445,24 @@ let GrowthZoneConnector = class GrowthZoneConnector extends BaseRESTIntegrationC
357
445
  }
358
446
  // ─── TestConnection ──────────────────────────────────────────────
359
447
  /**
360
- * Tests connectivity by calling the delta endpoint with a recent
361
- * watermark. A 200 response confirms that the tenant and API key are
362
- * valid.
448
+ * Tests connectivity by minting an OAuth2 token and listing one Contact record. A 2xx
449
+ * confirms the OAuth2 credentials + base URL are valid against the live API.
363
450
  */
364
451
  async TestConnection(companyIntegration, contextUser) {
365
452
  try {
366
- const config = await this.ParseConfig(companyIntegration, contextUser);
367
- const baseUrl = this.ResolveBaseUrl(config);
368
- const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
369
- const url = `${baseUrl}/contacts/delta?modifiedSince=${encodeURIComponent(since)}&top=1`;
370
- const response = await fetch(url, {
371
- method: 'GET',
372
- headers: {
373
- 'Authorization': `ApiKey ${config.ApiKey}`,
374
- 'Accept': 'application/json',
375
- },
376
- signal: AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS),
377
- });
378
- if (!response.ok) {
379
- const preview = (await response.text()).slice(0, 300);
453
+ const auth = (await this.Authenticate(companyIntegration, contextUser));
454
+ const headers = this.BuildHeaders(auth);
455
+ const url = `${auth.BaseUrl}/api/contacts?${ODATA_TOP_PARAM}=1`;
456
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
457
+ if (response.Status < 200 || response.Status >= 300) {
380
458
  return {
381
459
  Success: false,
382
- Message: `GrowthZone returned HTTP ${response.status} from ${url}: ${preview}`,
460
+ Message: `GrowthZone returned HTTP ${response.Status} from ${url}`,
383
461
  };
384
462
  }
385
463
  return {
386
464
  Success: true,
387
- Message: `Connected to GrowthZone tenant '${config.Tenant}'`,
465
+ Message: `Connected to GrowthZone at ${auth.BaseUrl}`,
388
466
  ServerVersion: 'GrowthZone REST API',
389
467
  };
390
468
  }
@@ -393,237 +471,50 @@ let GrowthZoneConnector = class GrowthZoneConnector extends BaseRESTIntegrationC
393
471
  return { Success: false, Message: `Connection failed: ${message}` };
394
472
  }
395
473
  }
396
- // ─── Discovery with runtime custom fields ───────────────────────
474
+ // ─── Discovery (metadata-driven, no hardcoded catalog) ───────────
397
475
  /**
398
- * Discovers objects from the cache. GrowthZone's dynamic capability is
399
- * limited to custom fields on contacts (not custom objects/tables) —
400
- * so DiscoverObjects only returns the metadata-seeded baseline.
476
+ * Discovers the full object universe from the IntegrationEngineBase cache (the Declared
477
+ * metadata). GrowthZone publishes its catalog credential-free (curated + dev docs), so
478
+ * the baseline is Declared metadata — never hardcoded here, never sampled at build.
401
479
  */
402
480
  async DiscoverObjects(companyIntegration, contextUser) {
403
- return super.DiscoverObjects(companyIntegration, contextUser);
404
- }
405
- /**
406
- * Discovers fields for a given object, augmenting the cached metadata
407
- * with runtime-discovered custom fields for contact objects. Custom
408
- * fields get IsCustom=true via IntegrationSchemaSync when persisted.
409
- */
481
+ const seeded = await super.DiscoverObjects(companyIntegration, contextUser);
482
+ if (seeded.length > 0)
483
+ return seeded;
484
+ // No seeded Declared metadata is loaded (a credential-free static context with no DB-backed
485
+ // IntegrationEngine — e.g. the structural self-check tiers). GrowthZone's object catalog is
486
+ // surfaced from the runtime-seeded Declared metadata (loaded from the DB) plus live-API custom
487
+ // fields; it is NOT a hardcoded code constant (per the no-hardcoded-catalog rule) and so cannot
488
+ // be reproduced from connector source without a live connection / loaded credential configuration.
489
+ // Signal that explicitly so credential-free self-check tiers SKIP honestly ("proven at the live
490
+ // tier") instead of misreading an empty result as catalog drift. In real runtime (and the hybrid
491
+ // e2e, where `mj sync push` seeds the catalog) `seeded` is non-empty and this never throws.
492
+ throw new Error('GrowthZone DiscoverObjects requires a live connection / loaded credential configuration JSON — the object catalog is runtime-seeded (Declared metadata) plus live-discovered, not statically reproducible credential-free.');
493
+ }
494
+ /** Discovers fields for an object from the cached Declared metadata. */
410
495
  async DiscoverFields(companyIntegration, objectName, contextUser) {
411
- const staticFields = await super.DiscoverFields(companyIntegration, objectName, contextUser);
412
- // Custom-field discovery only applies to the contact-custom-field-values
413
- // and custom-field-definitions objects.
414
- const lower = objectName.toLowerCase();
415
- if (lower !== 'custom-field-definitions' && lower !== 'contact-custom-field-values') {
416
- return staticFields;
417
- }
418
- try {
419
- const customFields = await this.FetchCustomFieldDefinitions(companyIntegration, contextUser);
420
- const discovered = customFields.map(cf => this.CustomFieldToSchema(cf));
421
- return [...staticFields, ...discovered];
422
- }
423
- catch (err) {
424
- const message = err instanceof Error ? err.message : String(err);
425
- console.warn(`[GrowthZone] Runtime custom-field discovery failed: ${message}`);
426
- return staticFields;
427
- }
428
- }
429
- /**
430
- * Fetches custom field definitions from GrowthZone. Uses a sample contact
431
- * ID to query NotesAndFields — since the curated API does not expose a
432
- * global custom-fields list endpoint, a sample contact is used to probe
433
- * the defined fields.
434
- */
435
- async FetchCustomFieldDefinitions(companyIntegration, contextUser) {
436
- const auth = (await this.Authenticate(companyIntegration, contextUser));
437
- const headers = this.BuildHeaders(auth);
438
- // Canonical vendor endpoint: POST /api/customfields/getall returns ALL
439
- // tenant-defined custom field definitions across all object types in one
440
- // call. Documented at documentation.growthzoneapp.com/CustomField.html.
441
- // Previously this method probed per-contact NotesAndFields, which was
442
- // both slow and limited to fields actually attached to the probed record.
443
- try {
444
- const url = `${auth.BaseUrl}/customfields/getall`;
445
- const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, {});
446
- if (response.Status >= 200 && response.Status < 300) {
447
- const body = response.Body;
448
- const items = Array.isArray(body) ? body : (body.Results ?? []);
449
- if (items.length > 0)
450
- return items;
451
- }
452
- }
453
- catch {
454
- // Fall through to legacy per-contact probe if the global endpoint isn't
455
- // available on the tenant (e.g., older deployments may not expose it).
456
- }
457
- // Legacy fallback: probe a single contact's NotesAndFields. Kept so older
458
- // GrowthZone deployments without /customfields/getall continue to work.
459
- const since = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
460
- const listUrl = `${auth.BaseUrl}/contacts/delta?modifiedSince=${encodeURIComponent(since)}&top=1`;
461
- const listResp = await this.MakeHTTPRequest(auth, listUrl, 'GET', headers);
462
- if (listResp.Status < 200 || listResp.Status >= 300)
463
- return [];
464
- const listBody = listResp.Body;
465
- const contacts = Array.isArray(listBody) ? listBody : (listBody.Results ?? []);
466
- if (contacts.length === 0)
467
- return [];
468
- const probeId = String(contacts[0].ContactId);
469
- const fieldsUrl = `${auth.BaseUrl}/contacts/${encodeURIComponent(probeId)}/NotesAndFields`;
470
- const fieldsResp = await this.MakeHTTPRequest(auth, fieldsUrl, 'GET', headers);
471
- if (fieldsResp.Status < 200 || fieldsResp.Status >= 300)
472
- return [];
473
- const notesAndFields = fieldsResp.Body;
474
- return notesAndFields.Fields ?? [];
475
- }
476
- /**
477
- * Maps a GrowthZone custom field definition to an ExternalFieldSchema.
478
- */
479
- CustomFieldToSchema(cf) {
480
- return {
481
- Name: `CustomField_${cf.CustomFieldId}`,
482
- Label: cf.DisplayName ?? cf.Name ?? `Custom Field ${cf.CustomFieldId}`,
483
- Description: cf.Description ?? undefined,
484
- DataType: this.MapCustomFieldType(cf.CustomFieldType),
485
- IsRequired: cf.IsRequired === true,
486
- IsUniqueKey: false,
487
- IsReadOnly: cf.IsArchived === true,
488
- IsForeignKey: false,
489
- };
490
- }
491
- /**
492
- * Maps GrowthZone custom-field type to generic integration type.
493
- */
494
- MapCustomFieldType(cfType) {
495
- const t = (cfType ?? '').toLowerCase();
496
- if (t === 'number')
497
- return 'decimal';
498
- if (t === 'date')
499
- return 'datetime';
500
- if (t === 'selectlist' || t === 'scale')
501
- return 'string';
502
- if (t === 'file')
503
- return 'string';
504
- return 'string';
496
+ return super.DiscoverFields(companyIntegration, objectName, contextUser);
505
497
  }
506
498
  // ─── FetchChanges override ──────────────────────────────────────
507
499
  /**
508
- * Override to store watermark/object-name context used by BuildPaginatedURL
509
- * and to advance the watermark from returned records.
500
+ * Sets the watermark context the OData URL builder needs, delegates
501
+ * the actual walk to the base (which descends nested Door→Segment template-var paths via
502
+ * FK metadata so nested IOs never silently return 0 rows), then advances the watermark
503
+ * from the returned records on the final batch only (partial-failure-safe).
510
504
  */
511
505
  async FetchChanges(ctx) {
512
- this.currentObjectName = ctx.ObjectName;
513
506
  this.currentWatermark = ctx.WatermarkValue ?? undefined;
514
507
  const result = await super.FetchChanges(ctx);
515
508
  const isFinal = !result.HasMore;
516
509
  const newWatermark = isFinal
517
510
  ? (this.ExtractLatestModifiedDate(result.Records) ?? ctx.WatermarkValue ?? undefined)
518
511
  : undefined;
519
- return {
520
- ...result,
521
- NewWatermarkValue: newWatermark,
522
- };
512
+ return { ...result, NewWatermarkValue: newWatermark };
523
513
  }
524
- // ─── Private helpers ────────────────────────────────────────────
514
+ // ─── Config parsing ──────────────────────────────────────────────
525
515
  /**
526
- * Normalizes a raw record: strips GrowthZone null-sentinel dates and
527
- * treats empty strings as null. Does NOT recurse into nested objects
528
- * beyond the top level — the base class handles that.
529
- */
530
- NormalizeRecord(record) {
531
- const out = {};
532
- for (const [key, value] of Object.entries(record)) {
533
- out[key] = this.NormalizeValue(value);
534
- }
535
- return out;
536
- }
537
- /** Converts GrowthZone sentinel values to null. */
538
- NormalizeValue(value) {
539
- if (value == null)
540
- return null;
541
- if (typeof value === 'string') {
542
- if (value === '')
543
- return null;
544
- if (value.startsWith(GROWTHZONE_NULL_DATE))
545
- return null;
546
- return value;
547
- }
548
- return value;
549
- }
550
- /**
551
- * For OrgGeneral / NotesAndFields responses, the current object being
552
- * fetched may request a flattened nested array rather than the root
553
- * record. Returns flattened rows when currentObjectName matches a
554
- * known nested-array target, else null.
555
- */
556
- MaybeFlattenNested(body) {
557
- const name = this.currentObjectName.toLowerCase();
558
- // NotesAndFields — flatten Fields[] array for custom field values
559
- if (name === 'contact-custom-field-values' && Array.isArray(body.Fields)) {
560
- const fields = body.Fields;
561
- const contactId = body.ContactId ?? body.contactId;
562
- return fields.map(cf => this.NormalizeRecord({
563
- ContactId: contactId ?? '',
564
- CustomFieldId: cf.CustomFieldId,
565
- Name: cf.Name ?? null,
566
- DisplayName: cf.DisplayName ?? null,
567
- CustomFieldType: cf.CustomFieldType ?? null,
568
- Value: this.StringifyCustomFieldValue(cf.Value),
569
- FieldGroupName: cf.FieldGroupName ?? null,
570
- IsPublic: cf.IsPublic ?? null,
571
- IsArchived: cf.IsArchived ?? null,
572
- }));
573
- }
574
- // OrgGeneral — flatten one of the nested arrays based on current object
575
- const nestedMap = {
576
- 'contact-activities': 'Activities',
577
- 'contact-categories': 'Categories',
578
- 'contact-communications': 'Communication',
579
- 'contact-sales-opportunities': 'SalesOpportunities',
580
- };
581
- const arrKey = nestedMap[name];
582
- if (arrKey && Array.isArray(body[arrKey])) {
583
- const rows = body[arrKey];
584
- const contactId = body.ContactId ?? body.contactId;
585
- return rows.map(row => this.NormalizeRecord({
586
- ContactId: contactId ?? '',
587
- ...row,
588
- }));
589
- }
590
- return null;
591
- }
592
- /** Stringifies custom-field values so they fit nvarchar storage. */
593
- StringifyCustomFieldValue(value) {
594
- if (value == null)
595
- return null;
596
- if (typeof value === 'string')
597
- return value;
598
- if (typeof value === 'number' || typeof value === 'boolean')
599
- return String(value);
600
- try {
601
- return JSON.stringify(value);
602
- }
603
- catch {
604
- return null;
605
- }
606
- }
607
- /**
608
- * Extracts the latest ModifiedDate from a batch of records for
609
- * watermark advancement.
610
- */
611
- ExtractLatestModifiedDate(records) {
612
- let latest = null;
613
- for (const rec of records) {
614
- const dateStr = rec.Fields?.ModifiedDate;
615
- if (typeof dateStr !== 'string' || dateStr.length === 0)
616
- continue;
617
- const d = new Date(dateStr);
618
- if (!isNaN(d.getTime()) && (latest === null || d > latest)) {
619
- latest = d;
620
- }
621
- }
622
- return latest ? latest.toISOString() : null;
623
- }
624
- /**
625
- * Parses connection config, preferring the attached credential entity
626
- * over the raw Configuration JSON.
516
+ * Parses the OAuth2 connection config, preferring the attached MJ Credential over the
517
+ * raw Configuration JSON. Credential bytes are resolved at runtime — never at build.
627
518
  */
628
519
  async ParseConfig(companyIntegration, contextUser) {
629
520
  if (companyIntegration.CredentialID) {
@@ -634,75 +525,141 @@ let GrowthZoneConnector = class GrowthZoneConnector extends BaseRESTIntegrationC
634
525
  }
635
526
  throw new Error('GrowthZone connector requires either CredentialID or Configuration JSON');
636
527
  }
637
- /** Loads config from the MJ: Credentials entity. */
528
+ /** Loads the OAuth2 config from the MJ: Credentials entity Values JSON. */
638
529
  async ParseConfigFromCredential(credentialID, contextUser, provider) {
639
530
  const md = provider ?? new Metadata();
640
531
  const cred = await md.GetEntityObject('MJ: Credentials', contextUser);
641
- await cred.Load(credentialID);
642
- if (!cred.Values)
643
- throw new Error('GrowthZone credential has no Values JSON');
532
+ const loaded = await cred.Load(credentialID);
533
+ if (!loaded || !cred.Values) {
534
+ throw new Error('GrowthZone credential could not be loaded or has no Values JSON');
535
+ }
644
536
  return this.ValidateConfig(JSON.parse(cred.Values));
645
537
  }
646
- /** Validates and applies defaults to the config. */
538
+ /** Validates the parsed config + applies defaults. Field names are case-insensitive. */
647
539
  ValidateConfig(raw) {
648
540
  if (!raw || typeof raw !== 'object') {
649
541
  throw new Error('GrowthZone configuration is not a valid object');
650
542
  }
651
- const get = (key) => {
652
- const obj = raw;
653
- const lower = key.toLowerCase();
654
- for (const [k, v] of Object.entries(obj)) {
655
- if (k.toLowerCase() === lower && typeof v === 'string')
656
- return v;
543
+ const obj = raw;
544
+ const getStr = (...keys) => {
545
+ for (const key of keys) {
546
+ const lower = key.toLowerCase();
547
+ for (const [k, v] of Object.entries(obj)) {
548
+ if (k.toLowerCase() === lower && typeof v === 'string' && v.length > 0)
549
+ return v;
550
+ }
657
551
  }
658
552
  return undefined;
659
553
  };
660
- const getNum = (key) => {
661
- const obj = raw;
662
- const lower = key.toLowerCase();
663
- for (const [k, v] of Object.entries(obj)) {
664
- if (k.toLowerCase() === lower && typeof v === 'number')
665
- return v;
554
+ const getNum = (...keys) => {
555
+ for (const key of keys) {
556
+ const lower = key.toLowerCase();
557
+ for (const [k, v] of Object.entries(obj)) {
558
+ if (k.toLowerCase() === lower && typeof v === 'number')
559
+ return v;
560
+ }
666
561
  }
667
562
  return undefined;
668
563
  };
669
- const tenant = get('tenant') ?? get('subdomain');
670
- const apiKey = get('apikey') ?? get('api_key') ?? get('key');
671
- const baseUrlOverride = get('baseurloverride') ?? get('base_url_override') ?? get('baseurl');
672
- if (!tenant && !baseUrlOverride) {
673
- throw new Error('GrowthZone configuration missing required field: Tenant (or BaseUrlOverride)');
674
- }
675
- if (!apiKey) {
676
- throw new Error('GrowthZone configuration missing required field: ApiKey');
564
+ const baseURL = getStr('baseurl', 'base_url');
565
+ if (!baseURL) {
566
+ throw new Error('GrowthZone configuration missing required field: BaseURL');
567
+ }
568
+ const clientId = getStr('clientid', 'client_id');
569
+ const clientSecret = getStr('clientsecret', 'client_secret');
570
+ if (!clientId || !clientSecret) {
571
+ throw new Error('GrowthZone OAuth2 configuration missing required field: ClientId / ClientSecret');
572
+ }
573
+ const refreshToken = getStr('refreshtoken', 'refresh_token');
574
+ const username = getStr('username', 'user');
575
+ const password = getStr('password', 'pass');
576
+ if (!refreshToken && !(username && password)) {
577
+ throw new Error('GrowthZone OAuth2 configuration requires a RefreshToken (primary grant) ' +
578
+ 'or Username + Password (fallback grant)');
677
579
  }
678
580
  return {
679
- Tenant: tenant ?? '',
680
- ApiKey: apiKey,
681
- BaseUrlOverride: baseUrlOverride,
581
+ ClientId: clientId,
582
+ ClientSecret: clientSecret,
583
+ RefreshToken: refreshToken,
584
+ Username: username,
585
+ Password: password,
586
+ Scopes: getStr('scopes', 'scope'),
587
+ BaseURL: baseURL,
588
+ TokenURL: getStr('tokenurl', 'token_url'),
589
+ Tenant: getStr('tenant', 'subdomain'),
682
590
  MaxRetries: getNum('maxretries') ?? DEFAULT_MAX_RETRIES,
683
591
  RequestTimeoutMs: getNum('requesttimeoutms') ?? DEFAULT_REQUEST_TIMEOUT_MS,
684
592
  MinRequestIntervalMs: getNum('minrequestintervalms') ?? DEFAULT_MIN_REQUEST_INTERVAL_MS,
685
593
  };
686
594
  }
687
- /**
688
- * Resolves the base URL from either an override or the tenant subdomain.
689
- * Always returns a URL ending in `/api` with no trailing slash.
690
- */
595
+ /** Resolves the API base URL from the credential's BaseURL (never a hardcoded subdomain). */
691
596
  ResolveBaseUrl(config) {
692
- if (config.BaseUrlOverride) {
693
- const trimmed = config.BaseUrlOverride.replace(/\/+$/, '');
694
- return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
597
+ return config.BaseURL.replace(/\/+$/, '');
598
+ }
599
+ /** Resolves the OAuth2 token endpoint: credential TokenURL override, else `{base}/oauth/token`. */
600
+ ResolveTokenURL(config, baseUrl) {
601
+ if (config.TokenURL && config.TokenURL.length > 0)
602
+ return config.TokenURL;
603
+ // Strip a trailing /api or /API segment so the token endpoint sits at the host root.
604
+ const root = baseUrl.replace(/\/api$/i, '');
605
+ return `${root}${DEFAULT_TOKEN_PATH}`;
606
+ }
607
+ // ─── Normalization helpers ───────────────────────────────────────
608
+ /** Normalizes a raw record's top-level values (null-date + empty-string → null). */
609
+ NormalizeRecord(record) {
610
+ const out = {};
611
+ for (const [key, value] of Object.entries(record)) {
612
+ out[key] = this.NormalizeValue(value);
613
+ }
614
+ return out;
615
+ }
616
+ /** Converts GrowthZone sentinel values to null. */
617
+ NormalizeValue(value) {
618
+ if (value == null)
619
+ return null;
620
+ if (typeof value === 'string') {
621
+ if (value === '')
622
+ return null;
623
+ if (value.startsWith(GROWTHZONE_NULL_DATE))
624
+ return null;
625
+ return value;
695
626
  }
696
- return `https://${config.Tenant}.growthzoneapp.com/api`;
627
+ return value;
697
628
  }
629
+ /** Extracts the latest ModifiedDate across a batch for watermark advancement. */
630
+ ExtractLatestModifiedDate(records) {
631
+ let latest = null;
632
+ for (const rec of records) {
633
+ const raw = rec.Fields?.ModifiedDate ?? rec.Fields?.modifiedDate;
634
+ if (typeof raw !== 'string' || raw.length === 0)
635
+ continue;
636
+ const d = new Date(raw);
637
+ if (!isNaN(d.getTime()) && (latest === null || d > latest))
638
+ latest = d;
639
+ }
640
+ return latest ? latest.toISOString() : null;
641
+ }
642
+ // ─── HTTP helpers ────────────────────────────────────────────────
698
643
  /** Throttle to respect GrowthZone's recommended minimum request interval. */
699
644
  async ThrottleIfNeeded(minIntervalMs) {
700
645
  const elapsed = Date.now() - this.lastRequestTime;
701
- if (elapsed < minIntervalMs) {
646
+ if (elapsed < minIntervalMs)
702
647
  await this.Sleep(minIntervalMs - elapsed);
703
- }
704
648
  }
705
- /** Exponential backoff delay for retry attempts. */
649
+ /** Parses a Retry-After header (seconds or http-date) into ms, if present. */
650
+ RetryAfterMs(response) {
651
+ const header = response.headers.get('retry-after');
652
+ if (!header)
653
+ return undefined;
654
+ const asSeconds = Number(header);
655
+ if (!isNaN(asSeconds))
656
+ return Math.max(0, asSeconds * 1_000);
657
+ const asDate = new Date(header).getTime();
658
+ if (!isNaN(asDate))
659
+ return Math.max(0, asDate - Date.now());
660
+ return undefined;
661
+ }
662
+ /** Exponential backoff delay for retry attempts (capped at 30s). */
706
663
  BackoffDelay(attempt) {
707
664
  return Math.min(2_000 * Math.pow(2, attempt), 30_000);
708
665
  }
@@ -740,6 +697,6 @@ GrowthZoneConnector = __decorate([
740
697
  RegisterClass(BaseIntegrationConnector, 'GrowthZoneConnector')
741
698
  ], GrowthZoneConnector);
742
699
  export { GrowthZoneConnector };
743
- /** Tree-shaking prevention function — import and call from module entry point */
700
+ /** Tree-shaking prevention — import and call from the package entry point. */
744
701
  export function LoadGrowthZoneConnector() { }
745
702
  //# sourceMappingURL=GrowthZoneConnector.js.map