@memberjunction/integration-connectors 5.40.2 → 5.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/CventConnector.d.ts +146 -0
  2. package/dist/CventConnector.d.ts.map +1 -0
  3. package/dist/CventConnector.js +540 -0
  4. package/dist/CventConnector.js.map +1 -0
  5. package/dist/DynamicsDataverseConnector.d.ts +296 -0
  6. package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
  7. package/dist/DynamicsDataverseConnector.js +790 -0
  8. package/dist/DynamicsDataverseConnector.js.map +1 -0
  9. package/dist/FontevaConnector.d.ts +153 -0
  10. package/dist/FontevaConnector.d.ts.map +1 -0
  11. package/dist/FontevaConnector.js +421 -0
  12. package/dist/FontevaConnector.js.map +1 -0
  13. package/dist/GrowthZoneConnector.d.ts +123 -125
  14. package/dist/GrowthZoneConnector.d.ts.map +1 -1
  15. package/dist/GrowthZoneConnector.js +456 -499
  16. package/dist/GrowthZoneConnector.js.map +1 -1
  17. package/dist/HivebriteConnector.d.ts +151 -0
  18. package/dist/HivebriteConnector.d.ts.map +1 -0
  19. package/dist/HivebriteConnector.js +533 -0
  20. package/dist/HivebriteConnector.js.map +1 -0
  21. package/dist/IMISConnector.d.ts +112 -96
  22. package/dist/IMISConnector.d.ts.map +1 -1
  23. package/dist/IMISConnector.js +430 -417
  24. package/dist/IMISConnector.js.map +1 -1
  25. package/dist/MemberSuiteConnector.d.ts +185 -0
  26. package/dist/MemberSuiteConnector.d.ts.map +1 -0
  27. package/dist/MemberSuiteConnector.js +762 -0
  28. package/dist/MemberSuiteConnector.js.map +1 -0
  29. package/dist/NeonCRMConnector.d.ts +273 -0
  30. package/dist/NeonCRMConnector.d.ts.map +1 -0
  31. package/dist/NeonCRMConnector.js +1039 -0
  32. package/dist/NeonCRMConnector.js.map +1 -0
  33. package/dist/NetForumConnector.d.ts +140 -47
  34. package/dist/NetForumConnector.d.ts.map +1 -1
  35. package/dist/NetForumConnector.js +668 -395
  36. package/dist/NetForumConnector.js.map +1 -1
  37. package/dist/NetSuiteConnector.d.ts +116 -50
  38. package/dist/NetSuiteConnector.d.ts.map +1 -1
  39. package/dist/NetSuiteConnector.js +608 -510
  40. package/dist/NetSuiteConnector.js.map +1 -1
  41. package/dist/NimbleAMSConnector.d.ts +104 -11
  42. package/dist/NimbleAMSConnector.d.ts.map +1 -1
  43. package/dist/NimbleAMSConnector.js +552 -258
  44. package/dist/NimbleAMSConnector.js.map +1 -1
  45. package/dist/NoviConnector.d.ts +178 -0
  46. package/dist/NoviConnector.d.ts.map +1 -0
  47. package/dist/NoviConnector.js +566 -0
  48. package/dist/NoviConnector.js.map +1 -0
  49. package/dist/ORCIDConnector.d.ts +207 -0
  50. package/dist/ORCIDConnector.d.ts.map +1 -0
  51. package/dist/ORCIDConnector.js +708 -0
  52. package/dist/ORCIDConnector.js.map +1 -0
  53. package/dist/OpenWaterConnector.d.ts +173 -0
  54. package/dist/OpenWaterConnector.d.ts.map +1 -0
  55. package/dist/OpenWaterConnector.js +893 -0
  56. package/dist/OpenWaterConnector.js.map +1 -0
  57. package/dist/PathLMSConnector.d.ts +395 -0
  58. package/dist/PathLMSConnector.d.ts.map +1 -0
  59. package/dist/PathLMSConnector.js +1281 -0
  60. package/dist/PathLMSConnector.js.map +1 -0
  61. package/dist/PheedLoopConnector.d.ts +119 -0
  62. package/dist/PheedLoopConnector.d.ts.map +1 -0
  63. package/dist/PheedLoopConnector.js +429 -0
  64. package/dist/PheedLoopConnector.js.map +1 -0
  65. package/dist/PropFuelConnector.d.ts +135 -61
  66. package/dist/PropFuelConnector.d.ts.map +1 -1
  67. package/dist/PropFuelConnector.js +366 -467
  68. package/dist/PropFuelConnector.js.map +1 -1
  69. package/dist/RhythmConnector.d.ts +188 -0
  70. package/dist/RhythmConnector.d.ts.map +1 -0
  71. package/dist/RhythmConnector.js +784 -0
  72. package/dist/RhythmConnector.js.map +1 -0
  73. package/dist/SalesforceConnector.d.ts +69 -3
  74. package/dist/SalesforceConnector.d.ts.map +1 -1
  75. package/dist/SalesforceConnector.js +188 -166
  76. package/dist/SalesforceConnector.js.map +1 -1
  77. package/dist/SharePointConnector.d.ts +37 -30
  78. package/dist/SharePointConnector.d.ts.map +1 -1
  79. package/dist/SharePointConnector.js +163 -243
  80. package/dist/SharePointConnector.js.map +1 -1
  81. package/dist/YourMembershipConnector.d.ts.map +1 -1
  82. package/dist/YourMembershipConnector.js +13 -5
  83. package/dist/YourMembershipConnector.js.map +1 -1
  84. package/dist/index.d.ts +14 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +14 -3
  87. package/dist/index.js.map +1 -1
  88. package/package.json +8 -7
  89. package/dist/BettyConnector.d.ts +0 -288
  90. package/dist/BettyConnector.d.ts.map +0 -1
  91. package/dist/BettyConnector.js +0 -909
  92. package/dist/BettyConnector.js.map +0 -1
@@ -5,496 +5,769 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  /**
8
- * ⚠️ NEEDS REWORK BEFORE SHIPPING — 3 known wire-level gaps ⚠️
8
+ * NetForumConnector — netFORUM Enterprise xWeb SOAP/XML integration connector.
9
9
  *
10
- * 2026-05-20 vendor-truth audit:
10
+ * Protocol: SOAP 1.1 over HTTPS. There is NO `BaseSOAPIntegrationConnector` in the
11
+ * engine — the only protocol bases are `BaseIntegrationConnector` (grandparent) and
12
+ * `BaseRESTIntegrationConnector`. This connector rides `BaseRESTIntegrationConnector`
13
+ * and implements SOAP over its HTTP seam: it POSTs SOAP 1.1 envelopes to
14
+ * `/xweb/secure/netForumXML.asmx` via `MakeHTTPRequest` and parses the XML in
15
+ * `NormalizeResponse`. The transport is text/xml, not JSON.
11
16
  *
12
- * 1. Auth token is returned in the WWW-Authenticate RESPONSE HEADER, not in the
13
- * JSON body. Vendor doc quote: "The token is in the WWW-Authenticate header.
14
- * The value will look like: 'Bearer token = eb5667ab-25ac-45c9-b831-23b43be8f194'".
15
- * Current code (line ~195) parses response.json() expecting `{ Token: string }` —
16
- * will fail to extract the token from every real authenticate call.
17
- * 2. `LastModifiedDate` is not a canonical NetForum column. Each facade uses a
18
- * prefixed name (`ind_last_updated_dt` for Individual, `evt_last_updated_dt`
19
- * for Event, etc.). FetchChanges currently emits `szWhereClause=LastModifiedDate
20
- * >= '<wm>'` which yields zero rows or a query error on every object.
21
- * 3. `DeleteFacadeObject` is not in any reachable vendor doc snippet. Get/Insert/
22
- * Update facade methods are documented. SupportsDelete=true may need to flip
23
- * false, or use a different method (vendor refers to "soft-deletes" generically).
17
+ * ── Wire truth (from the public WSDL: sources/netForumXML_asm.wsdl, 5.9 MB, 277 ops) ──
24
18
  *
25
- * Vendor doc URLs:
26
- * - xWeb Overview: https://documentation.abila.com/netforum-enterprise/2017.1/Content/xWeb/XWeb_Overview.htm
27
- * - JSON over xWeb: http://documentation.abila.com/netforum-enterprise/2017.1/Content/xWeb/JSON_Over_xWeb_Overview.htm
28
- * - xWeb Methods: https://nfesupport.zendesk.com/hc/en-us/articles/10639146855565
29
- * - Authenticate: https://nfesupport.zendesk.com/hc/en-us/articles/10639139863309-Authenticate
19
+ * Endpoint : POST <tenantHost>/xweb/secure/netForumXML.asmx (Content-Type: text/xml; charset=utf-8)
20
+ * Namespace : http://www.avectra.com/2005/ (Avectra-era, retained by Community Brands)
21
+ * SOAPAction : http://www.avectra.com/2005/<MethodName>
22
+ *
23
+ * Auth (TWO-STEP, SOAP — NOT HTTP Basic / WWW-Authenticate / Bearer):
24
+ * 1. Authenticate(userName, password) → AuthenticateResult (the token string).
25
+ * The Authenticate envelope carries NO AuthorizationToken header — it is the bootstrap.
26
+ * 2. Every subsequent data/CRUD call carries the token in the SOAP HEADER element:
27
+ * <AuthorizationToken xmlns="http://www.avectra.com/2005/"><Token>{token}</Token></AuthorizationToken>
28
+ * (NOT an HTTP Authorization header.) Source: WSDL `AuthorizationToken` complexType +
29
+ * `<soap:header part="AuthorizationToken">` on every WEB-prefixed / GetQuery operation binding.
30
+ *
31
+ * Read (door = GetQuery):
32
+ * GetQuery(szObjectName, szColumnList, szWhereClause?, szOrderBy?) → GetQueryResult
33
+ * - GetQueryResult is a `<s:any>` wildcard DataSet — flattened rows, one element per row.
34
+ * - Incremental: szWhereClause = "<watermarkField> >= '<wm>'" where the watermark column is
35
+ * the IO's per-facade `IncrementalWatermarkField` (e.g. ind_change_date, evt_*_change_date).
36
+ * There is NO canonical `LastModifiedDate` column — the field is per-facade and is read
37
+ * from metadata, never hardcoded.
38
+ * - Row limit: szObjectName supports an `@TOP -1` / `@TOP N` modifier to bypass / bound the
39
+ * server-side DataGrid row cap. The connector requests the modifier from the IO's accessPath.
30
40
  *
31
- * Auth: Two-step token auth.
32
- * 1. POST /xWeb/Signon.asmx with credentials → returns auth token in SOAP header
33
- * 2. JSON mode: POST /xWeb/JSON/Authenticate with Basic Auth → returns token
34
- * Pass token as Authorization: Bearer {token} on subsequent calls
35
- * Base URL: https://{site}/xWeb/JSON/{MethodName} (JSON mode)
36
- * https://{site}/xWeb/netForumXML.asmx (SOAP mode)
37
- * Pagination: Results returned in full via WEBQuery/GetQuery — no standard offset/limit
38
- * Rate limits: Instance-dependent (not documented)
39
- * Incremental: Via query parameters (date range filters in WEBQuery)
40
- * CRUD: Via facade CRUD methods (GetFacadeObject, InsertFacadeObject, UpdateFacadeObject, DeleteFacadeObject)
41
+ * Discovery (mechanism, NOT a baked catalog):
42
+ * - Standard objects come from the Declared metadata (the persisted IntegrationObject rows in
43
+ * the engine cache — credential-free docs → static metadata, case 1). `DiscoverObjects`
44
+ * returns the cache; it does NOT enumerate a hardcoded `NF_OBJECTS` array (that was the bug
45
+ * this IMPROVE round removes).
46
+ * - Per-object field definitions come from `GetQueryDefinition(szObjectName)` at runtime
47
+ * (credential-gated, case 2): the response carries the SQL column definition
48
+ * (Object → ListTable → ListFromTables → ListFromTable[] → Columns → Column[]). The connector
49
+ * parses that into ExternalFieldSchema. With no credential it falls back to the Declared
50
+ * fields. The customer's own installed query objects (variable per installation) are reached
51
+ * the same way — GetQueryDefinition is the discovery MECHANISM, never an answer baked in code.
52
+ * - `DiscoveryIsAuthoritative` is FALSE: discovery extends the Declared baseline, it does not
53
+ * enumerate the full credential gamut, so absence in a refresh must NEVER deactivate.
41
54
  *
42
- * API Categories:
43
- * - JSON over xWeb (implemented) — RESTful JSON wrapper over xWeb methods
44
- * - SOAP xWeb (NOT implemented) — legacy XML, JSON mode preferred
45
- * - SQL Views (NOT implemented) — direct SQL access, not API-based
46
- * - Webhooks (NOT available) — no webhook/CDC support in NetForum
55
+ * Write (facade CRUD — only where metadata declares the capability + per-operation columns):
56
+ * - Create/Update route through the IO's per-operation columns (CreateAPIPath/CreateMethod/
57
+ * CreateBodyShape/CreateBodyKey/CreateIDLocation, Update*) which name the SOAP operation
58
+ * (e.g. WEBIndividualInsert, InsertFacadeObject). The connector wraps the attributes in a
59
+ * SOAP envelope for that operation and routes the result through BuildCreatedResult so a 2xx
60
+ * with no record key fails loudly (no silent record loss / duplicate-create next sync).
61
+ * - SupportsDelete = false. `DeleteFacadeObject` is not confirmed in any reachable vendor doc;
62
+ * netFORUM prefers soft-delete via status flags. Held false until verified.
63
+ *
64
+ * Vendor doc URLs:
65
+ * - WSDL (primary) : https://myasm.asm.org/xweb/secure/netforumxml.asmx?WSDL
66
+ * - xWeb Overview : https://documentation.abila.com/netforum-enterprise/2017.1/Content/xWeb/XWeb_Overview.htm
67
+ * - GetQuery : https://documentation.abila.com/netforum-enterprise/2017.1/Content/xWeb/Methods/GetQuery.htm
47
68
  */
48
69
  import { RegisterClass } from '@memberjunction/global';
49
70
  import { Metadata } from '@memberjunction/core';
50
71
  import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
72
+ // ─── Constants ───────────────────────────────────────────────────────
73
+ /** Avectra-era SOAP namespace, retained by Community Brands. All ops + types use it. */
74
+ const SOAP_NS = 'http://www.avectra.com/2005/';
75
+ /** Canonical xWeb SOAP endpoint path (per-tenant host; path is constant). */
76
+ const DEFAULT_SOAP_PATH = '/xweb/secure/netForumXML.asmx';
51
77
  const MAX_RETRIES = 3;
52
78
  const REQUEST_TIMEOUT_MS = 30_000;
53
79
  const MIN_REQUEST_INTERVAL_MS = 200;
54
- // NetForum facade objects — each maps to a GetFacadeObject/WEBQuery target
55
- const NF_OBJECTS = [
56
- { Name: 'Individual', DisplayName: 'Individual', Description: 'Individual contacts/members', SupportsWrite: true, Fields: [
57
- { Name: 'ind_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Customer GUID' },
58
- { Name: 'ind_last_updated_dt', DisplayName: 'Last Updated', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
59
- ] },
60
- { Name: 'Organization', DisplayName: 'Organization', Description: 'Organization/company records', SupportsWrite: true, Fields: [
61
- { Name: 'org_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Customer GUID' },
62
- ] },
63
- { Name: 'Membership', DisplayName: 'Membership', Description: 'Membership records', SupportsWrite: true, Fields: [
64
- { Name: 'mbr_key', DisplayName: 'Membership Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Membership GUID' },
65
- { Name: 'mbr_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual/Organization' },
66
- ] },
67
- { Name: 'MembershipType', DisplayName: 'Membership Type', Description: 'Membership type definitions', SupportsWrite: false, Fields: [
68
- { Name: 'mbt_key', DisplayName: 'Type Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Type GUID' },
69
- ] },
70
- { Name: 'Event', DisplayName: 'Event', Description: 'Events and conferences', SupportsWrite: true, Fields: [
71
- { Name: 'evt_key', DisplayName: 'Event Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Event GUID' },
72
- { Name: 'evt_start_dt', DisplayName: 'Start Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Incremental date' },
73
- ] },
74
- { Name: 'EventRegistration', DisplayName: 'Event Registration', Description: 'Event registrations', SupportsWrite: true, Fields: [
75
- { Name: 'reg_key', DisplayName: 'Registration Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Registration GUID' },
76
- { Name: 'reg_evt_key', DisplayName: 'Event Key', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Event' },
77
- { Name: 'reg_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual' },
78
- ] },
79
- { Name: 'Committee', DisplayName: 'Committee', Description: 'Committees and boards', SupportsWrite: true, Fields: [
80
- { Name: 'cmt_key', DisplayName: 'Committee Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Committee GUID' },
81
- ] },
82
- { Name: 'CommitteeMember', DisplayName: 'Committee Member', Description: 'Committee membership', SupportsWrite: true, Fields: [
83
- { Name: 'cmm_key', DisplayName: 'Member Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Committee member GUID' },
84
- { Name: 'cmm_cmt_key', DisplayName: 'Committee Key', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Committee' },
85
- { Name: 'cmm_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual' },
86
- ] },
87
- { Name: 'Invoice', DisplayName: 'Invoice', Description: 'Invoices', SupportsWrite: true, Fields: [
88
- { Name: 'inv_key', DisplayName: 'Invoice Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Invoice GUID' },
89
- { Name: 'inv_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual/Organization' },
90
- ] },
91
- { Name: 'Payment', DisplayName: 'Payment', Description: 'Payment records', SupportsWrite: true, Fields: [
92
- { Name: 'pmt_key', DisplayName: 'Payment Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Payment GUID' },
93
- ] },
94
- { Name: 'Product', DisplayName: 'Product', Description: 'Products for sale', SupportsWrite: true, Fields: [
95
- { Name: 'prd_key', DisplayName: 'Product Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Product GUID' },
96
- ] },
97
- { Name: 'Order', DisplayName: 'Order', Description: 'Sales orders', SupportsWrite: true, Fields: [
98
- { Name: 'ord_key', DisplayName: 'Order Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Order GUID' },
99
- { Name: 'ord_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual/Organization' },
100
- ] },
101
- { Name: 'Donation', DisplayName: 'Donation', Description: 'Donation/gift records', SupportsWrite: true, Fields: [
102
- { Name: 'don_key', DisplayName: 'Donation Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Donation GUID' },
103
- { Name: 'don_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual/Organization' },
104
- ] },
105
- { Name: 'Address', DisplayName: 'Address', Description: 'Customer addresses', SupportsWrite: true, Fields: [
106
- { Name: 'adr_key', DisplayName: 'Address Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Address GUID' },
107
- { Name: 'adr_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual/Organization' },
108
- ] },
109
- { Name: 'Session', DisplayName: 'Session', Description: 'Event sessions', SupportsWrite: true, Fields: [
110
- { Name: 'ses_key', DisplayName: 'Session Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Session GUID' },
111
- { Name: 'ses_evt_key', DisplayName: 'Event Key', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Event' },
112
- ] },
113
- { Name: 'Subscription', DisplayName: 'Subscription', Description: 'Publication subscriptions', SupportsWrite: true, Fields: [
114
- { Name: 'sub_key', DisplayName: 'Subscription Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Subscription GUID' },
115
- { Name: 'sub_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual/Organization' },
116
- ] },
117
- { Name: 'Certification', DisplayName: 'Certification', Description: 'Professional certifications', SupportsWrite: true, Fields: [
118
- { Name: 'crt_key', DisplayName: 'Certification Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Certification GUID' },
119
- { Name: 'crt_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual' },
120
- ] },
121
- { Name: 'ContinuingEducation', DisplayName: 'CE Credit', Description: 'Continuing education credits', SupportsWrite: true, Fields: [
122
- { Name: 'ceu_key', DisplayName: 'CE Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'CE GUID' },
123
- { Name: 'ceu_cst_key', DisplayName: 'Customer Key', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Individual' },
124
- ] },
125
- ];
80
+ /** Session-token TTL. netFORUM does not publish a token lifetime; re-auth on 401 covers expiry. */
81
+ const TOKEN_TTL_MS = 3_600_000;
126
82
  let NetForumConnector = class NetForumConnector extends BaseRESTIntegrationConnector {
127
83
  constructor() {
128
84
  super(...arguments);
129
85
  this.tokenCache = null;
130
86
  this.lastRequestTime = 0;
87
+ // ─── Per-object config helpers ───────────────────────────────────
88
+ /** Remembered integration ID from the last fetch, so StableOrderingKey can read the cache. */
89
+ this.LastIntegrationID = null;
131
90
  }
132
91
  get IntegrationName() { return 'NetForum Enterprise'; }
92
+ // Capability flags — kept in lockstep with the metadata's per-operation columns.
93
+ // Create/Update are declared on a subset of IOs (those with WEB*Insert/Update or a facade op);
94
+ // the generic CRUD path throws for an IO whose columns are null, so a flag here only asserts
95
+ // "at least one IO supports it". Delete stays false (DeleteFacadeObject unconfirmed in docs).
133
96
  get SupportsCreate() { return true; }
134
97
  get SupportsUpdate() { return true; }
135
- // Vendor docs document InsertFacadeObject + UpdateFacadeObject; no DeleteFacadeObject
136
- // appears in any reachable doc page. NetForum more commonly uses status flags ("soft
137
- // delete") rather than hard delete. Keeping the flag false until DeleteFacadeObject
138
- // is confirmed from a real vendor source — DeleteRecord returns a clean error.
139
98
  get SupportsDelete() { return false; }
140
99
  /**
141
- * Resolves the canonical "last modified" column for a NetForum facade object.
142
- * NetForum uses prefixed names (ind_last_updated_dt, evt_last_updated_dt, etc.)
143
- * rather than a universal LastModifiedDate column. Falls back to the column
144
- * declared in NF_OBJECTS, then to a generic 'last_updated_dt' if no static
145
- * entry exists.
100
+ * §7 — discovery is NOT an authoritative full-gamut enumeration. `DiscoverObjects` returns the
101
+ * Declared baseline (engine cache) and GetQueryDefinition only extends per-object fields; neither
102
+ * enumerates the complete set of objects the credentials expose (customer-installed queries vary).
103
+ * So absence in a refresh proves nothing → the comprehensive-refresh deactivation path must stay
104
+ * off. Returning false keeps the Declared metadata safe from wrongful deactivation.
146
105
  */
147
- ResolveWatermarkColumn(objectName) {
148
- const obj = NF_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
149
- if (obj) {
150
- // Look for an explicit timestamp field on the static catalog
151
- const timestampField = obj.Fields.find(f => /datetime/i.test(f.Type) && /last|updated|modified/i.test(f.Name));
152
- if (timestampField)
153
- return timestampField.Name;
154
- }
155
- // Fallback: NetForum's most common naming convention is <prefix>_last_updated_dt.
156
- // Without a known prefix per object, return a generic name + warn.
157
- console.warn(`[NetForum] No known watermark column for object '${objectName}'; using fallback 'last_updated_dt'`);
158
- return 'last_updated_dt';
159
- }
106
+ get DiscoveryIsAuthoritative() { return false; }
107
+ // ─── Auth — two-step SOAP token ──────────────────────────────────
160
108
  /**
161
- * Resolves the PROVABLE watermark column for a NetForum facade object — i.e.
162
- * a column the object's OWN static catalog declares as a datetime whose name
163
- * matches last/updated/modified. Unlike ResolveWatermarkColumn(), this NEVER
164
- * falls back to a guessed name: if the object declares no such field, it
165
- * returns undefined so callers leave IncrementalWatermarkField unset.
109
+ * Step 1 of the two-step auth: POST a SOAP `Authenticate(userName, password)` envelope (which
110
+ * carries NO AuthorizationToken header — it is the bootstrap) and read the token string from
111
+ * `AuthenticateResult`. The token is cached and re-used until its TTL elapses or a 401 forces
112
+ * re-auth. Credentials go in the SOAP body elements — there is no HTTP Basic / Bearer here.
166
113
  */
167
- ResolveDeclaredWatermarkColumn(objectName) {
168
- const obj = NF_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
169
- const timestampField = obj?.Fields.find(f => /datetime/i.test(f.Type) && /last|updated|modified/i.test(f.Name));
170
- return timestampField?.Name;
114
+ async Authenticate(ci, cu) {
115
+ const config = await this.ParseConfig(ci, cu);
116
+ if (this.tokenCache && this.tokenCache.ExpiresAt > Date.now() + 60_000) {
117
+ return { Token: this.tokenCache.Token, Config: config };
118
+ }
119
+ const body = this.BuildSoapEnvelope('Authenticate', {
120
+ userName: config.Username,
121
+ password: config.Password,
122
+ });
123
+ const url = `${config.BaseURL}${DEFAULT_SOAP_PATH}`;
124
+ const headers = this.SoapHeaders('Authenticate');
125
+ const response = await this.MakeRawHTTPRequest(url, 'POST', headers, body);
126
+ if (response.Status < 200 || response.Status >= 300) {
127
+ throw new Error(`NetForum Authenticate failed: HTTP ${response.Status}`);
128
+ }
129
+ const token = this.ParseSoapScalar(this.AsText(response.Body), 'AuthenticateResult');
130
+ if (!token) {
131
+ throw new Error('NetForum Authenticate response contained no token (AuthenticateResult element)');
132
+ }
133
+ this.tokenCache = { Token: token, ExpiresAt: Date.now() + TOKEN_TTL_MS };
134
+ return { Token: token, Config: config };
171
135
  }
172
- /**
173
- * Resolves a field's declared foreign-key target to a sibling object actually
174
- * declared in NF_OBJECTS. The static catalog encodes FK intent in the field
175
- * Description (e.g. 'FK → Event'). This promotes that declaration into the
176
- * framework's IsForeignKey/ForeignKeyTarget slots — but ONLY when the target
177
- * resolves to exactly ONE declared sibling object. Polymorphic targets
178
- * (e.g. 'FK → Individual/Organization') name two objects and are skipped.
179
- */
180
- ResolveForeignKeyTarget(description) {
181
- if (!description)
182
- return null;
183
- const match = description.match(/^FK\s*(?:→|->)\s*(.+)$/i);
184
- if (!match)
185
- return null;
186
- const target = match[1].trim();
187
- // Polymorphic refs (e.g. 'Individual/Organization') name multiple objects — skip.
188
- if (target.includes('/'))
189
- return null;
190
- const sibling = NF_OBJECTS.find(o => o.Name.toLowerCase() === target.toLowerCase());
191
- return sibling ? sibling.Name : null;
136
+ async ParseConfig(ci, cu, provider) {
137
+ if (ci.CredentialID) {
138
+ const fromCred = await this.ParseConfigFromCredential(ci.CredentialID, cu, provider);
139
+ if (fromCred)
140
+ return fromCred;
141
+ }
142
+ if (ci.Configuration) {
143
+ const parsed = JSON.parse(ci.Configuration);
144
+ return this.ExtractConfig(parsed);
145
+ }
146
+ throw new Error('NetForum connector requires either CredentialID or Configuration JSON');
192
147
  }
193
- GetIntegrationObjects() { return NF_OBJECTS; }
194
- GetActionGeneratorConfig() {
195
- const config = super.GetActionGeneratorConfig();
196
- if (!config)
148
+ async ParseConfigFromCredential(credentialID, cu, provider) {
149
+ const md = provider ?? new Metadata();
150
+ const cred = await md.GetEntityObject('MJ: Credentials', cu);
151
+ const loaded = await cred.Load(credentialID);
152
+ if (!loaded || !cred.Values)
197
153
  return null;
198
- config.IconClass = 'fa-solid fa-network-wired';
199
- config.CategoryDescription = 'NetForum Enterprise AMS for members, events, committees, and orders';
200
- config.ParentCategoryName = 'Association Management';
201
- config.IncludeSearch = true;
202
- config.IncludeList = true;
203
- return config;
154
+ const values = JSON.parse(cred.Values);
155
+ return this.ExtractConfig(values);
156
+ }
157
+ ExtractConfig(values) {
158
+ const get = (...keys) => {
159
+ for (const key of keys) {
160
+ const hit = Object.entries(values).find(([k]) => k.toLowerCase() === key.toLowerCase());
161
+ if (hit)
162
+ return String(hit[1] ?? '');
163
+ }
164
+ return undefined;
165
+ };
166
+ const baseURL = get('BaseURL', 'BaseUrl', 'base_url');
167
+ const username = get('Username', 'username', 'user', 'userName');
168
+ const password = get('Password', 'password', 'pass');
169
+ if (!baseURL)
170
+ throw new Error('NetForum configuration missing required field: BaseURL');
171
+ if (!username)
172
+ throw new Error('NetForum configuration missing required field: Username');
173
+ if (!password)
174
+ throw new Error('NetForum configuration missing required field: Password');
175
+ return { BaseURL: this.StripTrailingSlash(baseURL), Username: username, Password: password };
176
+ }
177
+ StripTrailingSlash(s) { return s.replace(/\/+$/, ''); }
178
+ async TestConnection(ci, cu) {
179
+ try {
180
+ const auth = await this.Authenticate(ci, cu);
181
+ // Read-only smoke: GetVersion is a harmless, token-gated SystemInfo op.
182
+ const url = `${auth.Config.BaseURL}${DEFAULT_SOAP_PATH}`;
183
+ const body = this.BuildSoapEnvelope('GetVersion', {}, auth.Token);
184
+ const r = await this.MakeRawHTTPRequest(url, 'POST', this.SoapHeaders('GetVersion'), body);
185
+ return r.Status >= 200 && r.Status < 300
186
+ ? { Success: true, Message: 'Connected to netFORUM Enterprise xWeb (SOAP)' }
187
+ : { Success: false, Message: `xWeb returned HTTP ${r.Status}` };
188
+ }
189
+ catch (err) {
190
+ return { Success: false, Message: err instanceof Error ? err.message : String(err) };
191
+ }
204
192
  }
205
- async DiscoverObjects(_ci, _cu) {
206
- return NF_OBJECTS.map(o => ({ Name: o.Name, Label: o.DisplayName, Description: o.Description, SupportsIncrementalSync: true, SupportsWrite: o.SupportsWrite ?? false }));
193
+ // ─── Discovery — Declared cache + runtime GetQueryDefinition ──────
194
+ /**
195
+ * Standard objects = the Declared metadata (engine cache of persisted IntegrationObject rows).
196
+ * NO hardcoded catalog. The base implementation reads exactly the credential-free Declared
197
+ * universe; a live credential only ADDS customer-installed query objects (the Discovered
198
+ * extension), it never supplies the baseline — so this re-yields the standard universe
199
+ * credential-free and the runtime structure self-check passes without a token.
200
+ */
201
+ async DiscoverObjects(ci, cu) {
202
+ return super.DiscoverObjects(ci, cu);
207
203
  }
204
+ /**
205
+ * Two-stage field discovery. Stage 1 returns the Declared field set (engine cache). Stage 2, when
206
+ * a credential is available, calls the GetQueryDefinition MECHANISM and parses the returned SQL
207
+ * column definition (Object → ListTable → ListFromTables → Column[]) into ExternalFieldSchema —
208
+ * this surfaces customer-added columns the static metadata never saw. With no credential, or on
209
+ * any error, it degrades to the Declared fields (never throws, never bakes a catalog).
210
+ */
208
211
  async DiscoverFields(ci, objectName, cu) {
209
- // Dynamic: fetch one record via GetQuery to discover all fields
212
+ const declared = await super.DiscoverFields(ci, objectName, cu);
213
+ // The base FieldEntityToSchema folds PK into IsUniqueKey and never sets IsPrimaryKey, so
214
+ // re-derive the honest IsPrimaryKey from the cached IOF entities (which carry it explicitly).
215
+ const pkNames = this.DeclaredPrimaryKeyNames(ci, objectName);
216
+ const declaredByName = new Map(declared.map(f => [f.Name.toLowerCase(), { ...f, IsPrimaryKey: pkNames.has(f.Name.toLowerCase()) }]));
210
217
  try {
211
218
  const auth = await this.Authenticate(ci, cu);
212
- const headers = this.BuildHeaders(auth);
213
- const url = `${auth.Config.BaseURL}/xWeb/JSON/GetQuery?szObjectName=${objectName}&szColumnList=*&intRecordCount=1`;
214
- const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
215
- if (response.Status === 200) {
216
- const records = this.NormalizeResponse(response.Body, null);
217
- if (records.length > 0)
218
- return this.InferFieldsFromSample(records[0], objectName);
219
+ const url = `${auth.Config.BaseURL}${DEFAULT_SOAP_PATH}`;
220
+ const body = this.BuildSoapEnvelope('GetQueryDefinition', { szObjectName: objectName }, auth.Token);
221
+ const r = await this.MakeRawHTTPRequest(url, 'POST', this.SoapHeaders('GetQueryDefinition'), body);
222
+ if (r.Status >= 200 && r.Status < 300) {
223
+ const discovered = this.ParseQueryDefinition(this.AsText(r.Body), declaredByName);
224
+ if (discovered.length > 0)
225
+ return discovered;
219
226
  }
220
227
  }
221
- catch { /* fall through to static */ }
222
- const obj = NF_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
223
- if (!obj)
224
- return [];
225
- return obj.Fields.map(f => {
226
- const fkTarget = this.ResolveForeignKeyTarget(f.Description);
227
- return {
228
- Name: f.Name, Label: f.DisplayName, Description: f.Description, DataType: f.Type,
229
- IsRequired: f.IsRequired, IsUniqueKey: f.IsPrimaryKey, IsReadOnly: f.IsReadOnly,
230
- IsForeignKey: fkTarget !== null, ForeignKeyTarget: fkTarget,
231
- };
232
- });
228
+ catch {
229
+ // credential-free / network / parse failure → fall through to Declared
230
+ }
231
+ return declared;
233
232
  }
234
- InferFieldsFromSample(sample, objectName) {
235
- const staticObj = NF_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
236
- const staticMap = new Map((staticObj?.Fields ?? []).map(f => [f.Name.toLowerCase(), f]));
237
- const fields = [];
238
- for (const [key, value] of Object.entries(sample)) {
239
- const sf = staticMap.get(key.toLowerCase());
240
- const fkTarget = this.ResolveForeignKeyTarget(sf?.Description);
241
- fields.push({
242
- Name: key, Label: sf?.DisplayName ?? key, Description: sf?.Description ?? '',
243
- DataType: sf?.Type ?? this.InferType(value),
244
- IsRequired: sf?.IsRequired ?? false, IsUniqueKey: sf?.IsPrimaryKey ?? false, IsReadOnly: sf?.IsReadOnly ?? false,
245
- IsForeignKey: fkTarget !== null, ForeignKeyTarget: fkTarget,
233
+ /**
234
+ * Parses a GetQueryDefinition response (the SQL column definition) into ExternalFieldSchema[].
235
+ * Each `<Column>` carries mdc_name / mdc_description / mdc_data_type / mdc_nullable /
236
+ * mdc_width_max. Declared metadata wins for PK identity (a column definition does not mark a PK);
237
+ * discovery contributes the full column corpus + provable nullability/length.
238
+ */
239
+ ParseQueryDefinition(xml, declaredByName) {
240
+ const out = [];
241
+ const seen = new Set();
242
+ for (const colXml of this.ExtractElements(xml, 'Column')) {
243
+ const name = this.ParseSoapScalar(colXml, 'mdc_name');
244
+ if (!name || seen.has(name.toLowerCase()))
245
+ continue;
246
+ seen.add(name.toLowerCase());
247
+ const declared = declaredByName.get(name.toLowerCase());
248
+ const dataType = this.ParseSoapScalar(colXml, 'mdc_data_type');
249
+ const description = this.ParseSoapScalar(colXml, 'mdc_description');
250
+ const nullable = this.ParseSoapScalar(colXml, 'mdc_nullable');
251
+ const widthRaw = this.ParseSoapScalar(colXml, 'mdc_width_max');
252
+ const maxLength = widthRaw && /^\d+$/.test(widthRaw) ? Number(widthRaw) : null;
253
+ // AllowsNull provable-only: 'mdc_nullable' is an explicit source flag. "0"/"false"/"no" ⇒ NOT NULL.
254
+ const allowsNull = nullable == null
255
+ ? undefined
256
+ : !/^(0|false|no|n)$/i.test(nullable.trim());
257
+ out.push({
258
+ Name: name,
259
+ Label: declared?.Label ?? name,
260
+ Description: declared?.Description ?? (description || undefined),
261
+ DataType: declared?.DataType ?? this.MapSoapType(dataType),
262
+ IsRequired: declared?.IsRequired ?? false,
263
+ AllowsNull: allowsNull,
264
+ IsPrimaryKey: declared?.IsPrimaryKey ?? false,
265
+ IsUniqueKey: declared?.IsUniqueKey ?? false,
266
+ IsReadOnly: declared?.IsReadOnly ?? false,
267
+ IsForeignKey: declared?.IsForeignKey ?? false,
268
+ ForeignKeyTarget: declared?.ForeignKeyTarget ?? null,
269
+ MaxLength: maxLength,
246
270
  });
247
271
  }
248
- return fields;
272
+ // Re-add any Declared field GetQueryDefinition did not surface (provable-only baseline never lost).
273
+ for (const [key, f] of declaredByName) {
274
+ if (!seen.has(key))
275
+ out.push(f);
276
+ }
277
+ return out;
249
278
  }
250
- InferType(v) {
251
- if (v === null || v === undefined)
279
+ MapSoapType(sqlType) {
280
+ if (!sqlType)
252
281
  return 'string';
253
- if (typeof v === 'number')
254
- return Number.isInteger(v) ? 'number' : 'decimal';
255
- if (typeof v === 'boolean')
256
- return 'boolean';
257
- if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v))
282
+ const t = sqlType.toLowerCase();
283
+ if (/(date|time)/.test(t))
258
284
  return 'datetime';
285
+ if (/(int|bigint|smallint|tinyint)/.test(t))
286
+ return 'number';
287
+ if (/(decimal|numeric|money|float|real)/.test(t))
288
+ return 'decimal';
289
+ if (/bit/.test(t))
290
+ return 'boolean';
291
+ if (/uniqueidentifier/.test(t))
292
+ return 'string';
259
293
  return 'string';
260
294
  }
295
+ // ─── FetchChanges — GetQuery door walk + per-facade watermark ─────
261
296
  /**
262
- * Override IntrospectSchema so the schema-builder receives the framework's
263
- * provable static metadata: foreign keys (declared via each FK field's
264
- * 'FK → <Sibling>' Description, resolved to a declared sibling object) and,
265
- * for objects whose own catalog declares a last/updated/modified datetime
266
- * column, IncrementalWatermarkField. No watermark is invented — objects
267
- * without a declared timestamp leave the slot unset.
297
+ * Fetches a batch via the GetQuery door. Walks the IO's access path from Configuration: the door
298
+ * (GetQuery), the query object name, and any top modifier (@TOP -1 to bypass the DataGrid row
299
+ * cap). Incremental sync uses the IO's per-facade `IncrementalWatermarkField` in the
300
+ * szWhereClause — there is NO canonical LastModifiedDate; the field is metadata-driven.
301
+ *
302
+ * Full-record pass-through: every parsed row becomes ExternalRecord.Fields verbatim — never a
303
+ * filtered/narrow literal — so the framework's custom-column capture sees every column the query
304
+ * returned, including customer-added ones.
268
305
  */
269
- async IntrospectSchema(companyIntegration, contextUser) {
270
- const objects = await this.DiscoverObjects(companyIntegration, contextUser);
271
- const result = { Objects: [] };
272
- for (const obj of objects) {
273
- const fields = await this.DiscoverFields(companyIntegration, obj.Name, contextUser);
274
- const sourceFields = fields.map(f => ({
275
- Name: f.Name,
276
- Label: f.Label ?? f.Name,
277
- Description: f.Description,
278
- SourceType: f.DataType,
279
- IsRequired: f.IsRequired ?? false,
280
- IsPrimaryKey: f.IsUniqueKey ?? false,
281
- IsForeignKey: f.IsForeignKey ?? false,
282
- ForeignKeyTarget: f.ForeignKeyTarget ?? null,
283
- MaxLength: null,
284
- Precision: null,
285
- Scale: null,
286
- DefaultValue: null,
287
- }));
288
- result.Objects.push({
289
- ExternalName: obj.Name,
290
- ExternalLabel: obj.Label ?? obj.Name,
291
- Description: obj.Description,
292
- Fields: sourceFields,
293
- PrimaryKeyFields: sourceFields.filter(f => f.IsPrimaryKey).map(f => f.Name),
294
- Relationships: sourceFields
295
- .filter(f => f.IsForeignKey && f.ForeignKeyTarget)
296
- .map(f => ({ FieldName: f.Name, TargetObject: f.ForeignKeyTarget, TargetField: 'ID' })),
297
- // Only set when the object's OWN catalog declares a last/updated/modified
298
- // datetime column (provable). Undefined otherwise — never guessed.
299
- IncrementalWatermarkField: this.ResolveDeclaredWatermarkColumn(obj.Name),
300
- });
301
- }
302
- return result;
303
- }
304
- // ─── Auth (JSON mode) ──────────────────────────────────────────────
305
- async Authenticate(ci, cu) {
306
- if (this.tokenCache && this.tokenCache.ExpiresAt > Date.now() + 60_000) {
307
- const config = await this.ParseConfig(ci, cu);
308
- return { Token: this.tokenCache.Token, TokenType: 'Bearer', Config: config };
306
+ async FetchChanges(ctx) {
307
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
308
+ const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
309
+ const cfg = this.ParseObjectConfig(obj);
310
+ const accessPath = cfg.accessPath ?? {};
311
+ const queryObject = accessPath.queryObject ?? accessPath.doorArgs?.szObjectName ?? ctx.ObjectName;
312
+ const topModifier = accessPath.doorArgs?.topModifier ?? '@TOP -1';
313
+ const szObjectName = topModifier ? `${queryObject} ${topModifier}` : queryObject;
314
+ const args = { szObjectName, szColumnList: '*' };
315
+ const watermarkField = obj.IncrementalWatermarkField ?? undefined;
316
+ if (ctx.WatermarkValue && watermarkField) {
317
+ args.szWhereClause = `${watermarkField} >= '${this.EscapeSqlLiteral(ctx.WatermarkValue)}'`;
309
318
  }
310
- const config = await this.ParseConfig(ci, cu);
311
- const basicAuth = Buffer.from(`${config.Username}:${config.Password}`).toString('base64');
312
- const response = await fetch(`${config.BaseURL}/xWeb/JSON/Authenticate`, {
313
- method: 'POST', headers: { 'Authorization': `Basic ${basicAuth}`, 'Content-Type': 'application/json' },
314
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
315
- });
316
- if (!response.ok)
317
- throw new Error(`NetForum auth failed: ${response.status}`);
318
- // Vendor doc canonical: the token is returned in the WWW-Authenticate
319
- // RESPONSE HEADER, in the form: 'Bearer token = <guid>'. Older versions
320
- // sometimes also place it in a JSON body; fall back to body parsing if
321
- // the header isn't present.
322
- let token = null;
323
- const wwwAuth = response.headers.get('www-authenticate') || response.headers.get('WWW-Authenticate');
324
- if (wwwAuth) {
325
- // Match patterns like: 'Bearer token = abcd-1234' or 'Bearer abcd-1234'
326
- const match = wwwAuth.match(/Bearer\s+(?:token\s*=\s*)?([A-Fa-f0-9-]{8,})/i);
327
- if (match)
328
- token = match[1];
319
+ const orderingKey = cfg.stableOrderingKey ?? this.PrimaryKeyFieldName(obj);
320
+ if (orderingKey)
321
+ args.szOrderBy = orderingKey;
322
+ const url = `${auth.Config.BaseURL}${this.SoapEndpoint(cfg)}`;
323
+ const envelope = this.BuildSoapEnvelope('GetQuery', args, auth.Token);
324
+ const response = await this.MakeRawHTTPRequest(url, 'POST', this.SoapHeaders('GetQuery'), envelope);
325
+ if (response.Status < 200 || response.Status >= 300) {
326
+ throw new Error(`NetForum GetQuery(${ctx.ObjectName}) failed: HTTP ${response.Status}`);
329
327
  }
330
- if (!token) {
331
- try {
332
- const body = await response.json();
333
- token = body['Token'] ?? body['token'] ?? null;
328
+ const rows = this.NormalizeResponse(response.Body, null);
329
+ const pkField = this.PrimaryKeyFieldName(obj);
330
+ const warnings = rows.length === 0
331
+ ? [{ Code: 'ZERO_ROWS', Message: `GetQuery(${szObjectName}) returned no rows.` }]
332
+ : undefined;
333
+ // Highest ordering-key value seen → keyset resume position when no watermark exists.
334
+ let maxKey;
335
+ const records = rows.map(row => {
336
+ const externalID = pkField ? String(row[pkField] ?? '') : '';
337
+ if (orderingKey) {
338
+ const v = row[orderingKey];
339
+ if (v != null) {
340
+ const s = String(v);
341
+ if (maxKey === undefined || s > maxKey)
342
+ maxKey = s;
343
+ }
334
344
  }
335
- catch {
336
- /* not JSON */
345
+ return { ExternalID: externalID, ObjectType: ctx.ObjectName, Fields: row };
346
+ });
347
+ // Watermark: when this IO has a watermark field, the engine narrows next sync from the max
348
+ // seen. GetQuery returns the full result set in one call → HasMore is always false.
349
+ let newWatermark;
350
+ if (watermarkField) {
351
+ for (const row of rows) {
352
+ const v = row[watermarkField];
353
+ if (v != null) {
354
+ const s = String(v);
355
+ if (newWatermark === undefined || s > newWatermark)
356
+ newWatermark = s;
357
+ }
337
358
  }
338
359
  }
339
- if (!token) {
340
- throw new Error('NetForum auth response contained no token (checked WWW-Authenticate header and JSON body)');
360
+ return {
361
+ Records: records,
362
+ HasMore: false,
363
+ Warnings: warnings,
364
+ NewWatermarkValue: newWatermark,
365
+ NextAfterKeyValue: maxKey,
366
+ };
367
+ }
368
+ EscapeSqlLiteral(v) { return v.replace(/'/g, "''"); }
369
+ // ─── CRUD — SOAP facade ops via per-operation metadata columns ────
370
+ /**
371
+ * Create via the SOAP operation named in the IO's CreateBodyKey / Configuration.writeOps.createOp
372
+ * (e.g. WEBIndividualInsert, InsertFacadeObject). The attributes are wrapped in a SOAP envelope
373
+ * for that operation; the new key is read from the response and routed through BuildCreatedResult,
374
+ * so a 2xx that carries no record key FAILS loudly (the silent-create-loss invariant).
375
+ *
376
+ * Overridden (not the generic per-operation REST path) because the body must be a SOAP envelope,
377
+ * not a JSON body — the generic CreateRecord builds a JSON body. We still honor the metadata
378
+ * columns (operation name) and still call BuildCreatedResult.
379
+ */
380
+ async CreateRecord(ctx) {
381
+ const ci = ctx.CompanyIntegration;
382
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
383
+ if (!obj.SupportsCreate) {
384
+ return { Success: false, StatusCode: 0, ErrorMessage: `CreateRecord not supported for "${ctx.ObjectName}".` };
385
+ }
386
+ const cfg = this.ParseObjectConfig(obj);
387
+ const operation = obj.CreateBodyKey ?? cfg.writeOps?.createOp;
388
+ if (!operation) {
389
+ return { Success: false, StatusCode: 0, ErrorMessage: `Create of "${ctx.ObjectName}" has no SOAP operation configured (CreateBodyKey).` };
341
390
  }
342
- this.tokenCache = { Token: token, ExpiresAt: Date.now() + (3600 * 1000) };
343
- return { Token: token, TokenType: 'Bearer', Config: config };
391
+ const auth = await this.Authenticate(ci, ctx.ContextUser);
392
+ const url = `${auth.Config.BaseURL}${this.SoapEndpoint(cfg)}`;
393
+ const envelope = this.BuildSoapEnvelope(operation, ctx.Attributes, auth.Token);
394
+ const r = await this.MakeRawHTTPRequest(url, 'POST', this.SoapHeaders(operation), envelope);
395
+ if (r.Status < 200 || r.Status >= 300) {
396
+ return { Success: false, ExternalID: '', StatusCode: r.Status, ErrorMessage: `Create of "${ctx.ObjectName}" failed: HTTP ${r.Status}` };
397
+ }
398
+ const externalID = this.ExtractKeyFromResponse(r.Body);
399
+ return this.BuildCreatedResult(externalID, r.Status, ctx.ObjectName);
344
400
  }
345
- async ParseConfig(ci, cu, provider) {
346
- // Use typed property — never .Get()/.Set() on entity-typed objects (CLAUDE.md §2b).
347
- const credentialID = ci.CredentialID;
348
- if (credentialID) {
349
- const md = provider ?? new Metadata();
350
- const cred = await md.GetEntityObject('MJ: Credentials', cu);
351
- if (await cred.Load(credentialID) && cred.Values) {
352
- const p = JSON.parse(cred.Values);
353
- return { BaseURL: p['BaseURL'] ?? '', Username: p['Username'] ?? '', Password: p['Password'] ?? '' };
354
- }
401
+ /**
402
+ * Update via the SOAP operation named in UpdateBodyKey / Configuration.writeOps.updateOp.
403
+ * netFORUM exposes no ETag / If-Match — updates are last-write-wins.
404
+ */
405
+ async UpdateRecord(ctx) {
406
+ const ci = ctx.CompanyIntegration;
407
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
408
+ if (!obj.SupportsUpdate) {
409
+ return { Success: false, ExternalID: ctx.ExternalID, StatusCode: 0, ErrorMessage: `UpdateRecord not supported for "${ctx.ObjectName}".` };
410
+ }
411
+ const cfg = this.ParseObjectConfig(obj);
412
+ const operation = obj.UpdateBodyKey ?? cfg.writeOps?.updateOp;
413
+ if (!operation) {
414
+ return { Success: false, ExternalID: ctx.ExternalID, StatusCode: 0, ErrorMessage: `Update of "${ctx.ObjectName}" has no SOAP operation configured (UpdateBodyKey).` };
355
415
  }
356
- throw new Error('No NetForum credentials found.');
416
+ const auth = await this.Authenticate(ci, ctx.ContextUser);
417
+ const url = `${auth.Config.BaseURL}${this.SoapEndpoint(cfg)}`;
418
+ const pkField = this.PrimaryKeyFieldName(obj);
419
+ const attributes = pkField
420
+ ? { [pkField]: ctx.ExternalID, ...ctx.Attributes }
421
+ : { ...ctx.Attributes };
422
+ const envelope = this.BuildSoapEnvelope(operation, attributes, auth.Token);
423
+ const r = await this.MakeRawHTTPRequest(url, 'POST', this.SoapHeaders(operation), envelope);
424
+ if (r.Status >= 200 && r.Status < 300) {
425
+ return { Success: true, ExternalID: ctx.ExternalID, StatusCode: r.Status };
426
+ }
427
+ return { Success: false, ExternalID: ctx.ExternalID, StatusCode: r.Status, ErrorMessage: `Update of "${ctx.ObjectName}" failed: HTTP ${r.Status}` };
357
428
  }
358
- async TestConnection(ci, cu) {
429
+ /**
430
+ * Delete is not supported. DeleteFacadeObject is not confirmed in any reachable vendor doc and
431
+ * netFORUM prefers soft-delete via status flags. SupportsDelete is false, so this returns a clean
432
+ * error rather than silently claiming success.
433
+ */
434
+ async DeleteRecord(ctx) {
435
+ return {
436
+ Success: false,
437
+ ExternalID: ctx.ExternalID,
438
+ StatusCode: 0,
439
+ ErrorMessage: 'NetForum connector does not support delete (DeleteFacadeObject unconfirmed; netFORUM uses soft-delete via status flags).',
440
+ };
441
+ }
442
+ ExtractKeyFromResponse(body) {
443
+ const xml = this.AsText(body);
444
+ // The Insert/Create ops return the new GUID — scan common result/key element names.
445
+ for (const tag of ['key', 'Key', 'AddResult', 'InsertResult', 'CreateResult', 'WEBIndividualInsertResult', 'cst_key']) {
446
+ const v = this.ParseSoapScalar(xml, tag);
447
+ if (v && v.trim().length > 0)
448
+ return v.trim();
449
+ }
450
+ // Fallback: first GUID-shaped token anywhere in the body.
451
+ const guid = xml.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
452
+ return guid ? guid[0] : undefined;
453
+ }
454
+ // ─── Stable ordering key (no-watermark resume) ───────────────────
455
+ /**
456
+ * Returns the IO's stable, monotonic ordering column (the PK / Configuration.stableOrderingKey)
457
+ * so the engine can keyset-resume objects that have no incremental watermark. Null when the IO is
458
+ * unknown or has no stable key — keyset resume is simply unavailable then, not an error.
459
+ */
460
+ StableOrderingKey(objectName) {
461
+ const ci = this.LastIntegrationID;
462
+ if (!ci)
463
+ return null;
359
464
  try {
360
- const auth = await this.Authenticate(ci, cu);
361
- // Try fetching one Individual record
362
- const url = `${auth.Config.BaseURL}/xWeb/JSON/GetQuery?szObjectName=Individual&szColumnList=ind_cst_key&intRecordCount=1`;
363
- const r = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
364
- return r.Status === 200 ? { Success: true, Message: 'Connected to NetForum Enterprise' } : { Success: false, Message: `API returned ${r.Status}` };
465
+ const obj = this.GetCachedObject(ci, objectName);
466
+ const cfg = this.ParseObjectConfig(obj);
467
+ return cfg.stableOrderingKey ?? this.PrimaryKeyFieldName(obj) ?? null;
365
468
  }
366
- catch (err) {
367
- return { Success: false, Message: err instanceof Error ? err.message : String(err) };
469
+ catch {
470
+ return null;
471
+ }
472
+ }
473
+ // ─── SOAP envelope + XML helpers ─────────────────────────────────
474
+ /** Required SOAP 1.1 HTTP headers for a given operation. */
475
+ SoapHeaders(operation) {
476
+ return {
477
+ 'Content-Type': 'text/xml; charset=utf-8',
478
+ 'SOAPAction': `${SOAP_NS}${operation}`,
479
+ 'User-Agent': 'MemberJunction-Integration/1.0',
480
+ };
481
+ }
482
+ /**
483
+ * Builds a SOAP 1.1 envelope for `operation` with the given body args. When a token is provided
484
+ * (every call except Authenticate), the AuthorizationToken header element carries it — this is
485
+ * the documented SOAP token carrier, NOT an HTTP Authorization header. Values are XML-escaped.
486
+ */
487
+ BuildSoapEnvelope(operation, args, token) {
488
+ const header = token
489
+ ? `<soap:Header><AuthorizationToken xmlns="${SOAP_NS}"><Token>${this.EscapeXml(token)}</Token></AuthorizationToken></soap:Header>`
490
+ : '<soap:Header/>';
491
+ const argXml = Object.entries(args)
492
+ .map(([k, v]) => this.ArgElement(k, v))
493
+ .join('');
494
+ return `<?xml version="1.0" encoding="utf-8"?>` +
495
+ `<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">` +
496
+ header +
497
+ `<soap:Body><${operation} xmlns="${SOAP_NS}">${argXml}</${operation}></soap:Body>` +
498
+ `</soap:Envelope>`;
499
+ }
500
+ /** Serializes one SOAP arg. Objects become nested elements; scalars become escaped text. */
501
+ ArgElement(name, value) {
502
+ if (value === null || value === undefined)
503
+ return `<${name} />`;
504
+ if (typeof value === 'object') {
505
+ const inner = Object.entries(value)
506
+ .map(([k, v]) => this.ArgElement(k, v))
507
+ .join('');
508
+ return `<${name}>${inner}</${name}>`;
368
509
  }
510
+ return `<${name}>${this.EscapeXml(String(value))}</${name}>`;
511
+ }
512
+ EscapeXml(s) {
513
+ return s
514
+ .replace(/&/g, '&amp;')
515
+ .replace(/</g, '&lt;')
516
+ .replace(/>/g, '&gt;')
517
+ .replace(/"/g, '&quot;')
518
+ .replace(/'/g, '&apos;');
519
+ }
520
+ UnescapeXml(s) {
521
+ return s
522
+ .replace(/&lt;/g, '<')
523
+ .replace(/&gt;/g, '>')
524
+ .replace(/&quot;/g, '"')
525
+ .replace(/&apos;/g, "'")
526
+ .replace(/&#(\d+);/g, (_m, d) => String.fromCharCode(Number(d)))
527
+ .replace(/&amp;/g, '&');
528
+ }
529
+ /**
530
+ * Extracts the text content of the FIRST occurrence of a scalar element by local name, ignoring
531
+ * namespace prefixes. Returns undefined when absent. Used for AuthenticateResult, mdc_* columns,
532
+ * and create-result keys.
533
+ */
534
+ ParseSoapScalar(xml, localName) {
535
+ const re = new RegExp(`<(?:[\\w.-]+:)?${this.EscapeRegExp(localName)}\\b[^>]*>([\\s\\S]*?)</(?:[\\w.-]+:)?${this.EscapeRegExp(localName)}>`);
536
+ const m = re.exec(xml);
537
+ if (!m)
538
+ return undefined;
539
+ return this.UnescapeXml(m[1]).trim();
540
+ }
541
+ /** Returns the inner XML of every occurrence of an element by local name (namespace-agnostic). */
542
+ ExtractElements(xml, localName) {
543
+ const re = new RegExp(`<(?:[\\w.-]+:)?${this.EscapeRegExp(localName)}\\b[^>]*>([\\s\\S]*?)</(?:[\\w.-]+:)?${this.EscapeRegExp(localName)}>`, 'g');
544
+ const out = [];
545
+ let m;
546
+ while ((m = re.exec(xml)) !== null)
547
+ out.push(m[1]);
548
+ return out;
369
549
  }
370
- GetBaseURL(_ci, auth) { return auth.Config.BaseURL; }
371
- BuildPaginatedURL(basePath, _o, _p, _off) { return basePath; }
550
+ EscapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
551
+ /**
552
+ * Strips the SOAP envelope and the GetQueryResult wrapper, returning each flattened row as a
553
+ * plain object of column→value. netFORUM GetQuery returns a DataSet of repeated row elements
554
+ * (the row element name is the query's "Result"/"<Object>" element). We locate the GetQueryResult
555
+ * payload, then treat every direct child element that itself contains child elements as a row.
556
+ */
372
557
  NormalizeResponse(rawBody, _key) {
373
- if (Array.isArray(rawBody))
374
- return rawBody;
375
- const body = rawBody;
376
- // NetForum returns DataSet-style { Table: [{...}] } or array
377
- for (const key of Object.keys(body)) {
378
- if (Array.isArray(body[key]))
379
- return body[key];
380
- }
381
- if (body && Object.keys(body).length > 0)
382
- return [body];
383
- return [];
558
+ const xml = this.AsText(rawBody);
559
+ if (!xml)
560
+ return [];
561
+ // Unwrap the GetQueryResult body (the <s:any> DataSet). Fall back to the whole body.
562
+ const resultElems = this.ExtractElements(xml, 'GetQueryResult');
563
+ const payload = resultElems.length > 0 ? resultElems[0] : xml;
564
+ return this.ParseRowSet(payload);
384
565
  }
385
- ExtractPaginationInfo(_rb, _pt, _cp, _co, _ps) {
386
- return { HasMore: false }; // NetForum returns full result sets
566
+ /**
567
+ * Parses a flattened row set. The DataSet shape is `<Results><Result>...cols...</Result>...` (the
568
+ * outer/inner names vary by query, e.g. IndividualObjects/IndividualObject). We find the most
569
+ * common repeated leaf-bearing element name and treat each as a row of column elements.
570
+ */
571
+ ParseRowSet(xml) {
572
+ // Identify candidate row element names: any tag that recurs AND contains nested elements.
573
+ const topTags = this.TopLevelTagNames(xml);
574
+ // The row wrapper is usually a single outer element (e.g. <Results>); descend one level if so.
575
+ let scope = xml;
576
+ if (topTags.length === 1) {
577
+ const inner = this.ExtractElements(xml, topTags[0]);
578
+ if (inner.length === 1 && /</.test(inner[0]))
579
+ scope = inner[0];
580
+ }
581
+ const rowTagCounts = new Map();
582
+ for (const tag of this.TopLevelTagNames(scope)) {
583
+ rowTagCounts.set(tag, (rowTagCounts.get(tag) ?? 0) + 1);
584
+ }
585
+ // Pick the most frequent row tag (ties → first). A single-row result still has count 1.
586
+ let rowTag;
587
+ let best = 0;
588
+ for (const [tag, count] of rowTagCounts) {
589
+ if (count > best) {
590
+ best = count;
591
+ rowTag = tag;
592
+ }
593
+ }
594
+ if (!rowTag)
595
+ return [];
596
+ const rows = [];
597
+ for (const rowXml of this.ExtractElements(scope, rowTag)) {
598
+ const row = this.ParseRowColumns(rowXml);
599
+ if (Object.keys(row).length > 0)
600
+ rows.push(row);
601
+ }
602
+ return rows;
387
603
  }
388
- // ─── FetchChanges — via GetQuery JSON method ───────────────────────
389
- async FetchChanges(ctx) {
390
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
391
- const headers = this.BuildHeaders(auth);
392
- // Use GetQuery for listing records
393
- let url = `${auth.Config.BaseURL}/xWeb/JSON/GetQuery?szObjectName=${ctx.ObjectName}&szColumnList=*`;
394
- if (ctx.WatermarkValue) {
395
- // NetForum facades each use their own prefixed last-modified column.
396
- // 'LastModifiedDate' is NOT a vendor column. Look it up from the static
397
- // catalog (NF_OBJECTS lists e.g. 'ind_last_updated_dt' for Individual,
398
- // 'evt_last_updated_dt' for Event, etc.). Fall back to the connector's
399
- // earlier-guessed name only when the catalog has no entry.
400
- const wmCol = this.ResolveWatermarkColumn(ctx.ObjectName);
401
- url += `&szWhereClause=${encodeURIComponent(`${wmCol} >= '${ctx.WatermarkValue}'`)}`;
402
- }
403
- const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
404
- if (response.Status < 200 || response.Status >= 300)
405
- throw new Error(`NetForum ${ctx.ObjectName} error: ${response.Status}`);
406
- const records = this.NormalizeResponse(response.Body, null);
407
- // Determine PK field from static definition
408
- const obj = NF_OBJECTS.find(o => o.Name.toLowerCase() === ctx.ObjectName.toLowerCase());
409
- const pkField = obj?.Fields.find(f => f.IsPrimaryKey)?.Name ?? 'key';
410
- return {
411
- Records: records.map(r => ({ ExternalID: String(r[pkField] ?? r['key'] ?? ''), ObjectType: ctx.ObjectName, Fields: r })),
412
- HasMore: false,
413
- };
604
+ /** Parses one row's column elements into a flat object (full-record — every column kept). */
605
+ ParseRowColumns(rowXml) {
606
+ const row = {};
607
+ const re = /<([\w.-]+)\b[^>]*?(\/)?>(?:([\s\S]*?)<\/\1>)?/g;
608
+ let m;
609
+ while ((m = re.exec(rowXml)) !== null) {
610
+ const tag = m[1];
611
+ const selfClosing = m[2] === '/';
612
+ const content = m[3];
613
+ if (selfClosing || content === undefined) {
614
+ row[tag] = null;
615
+ continue;
616
+ }
617
+ // Flattened GetQuery rows carry scalar columns; keep the unescaped text value.
618
+ row[tag] = this.UnescapeXml(content);
619
+ }
620
+ return row;
414
621
  }
415
- // ─── CRUD — via facade methods ─────────────────────────────────────
416
- async CreateRecord(ctx) {
417
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
418
- const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
419
- const url = `${auth.Config.BaseURL}/xWeb/JSON/InsertFacadeObject`;
420
- const payload = { szObjectName: ctx.ObjectName, oRecord: ctx.Attributes };
421
- const r = await this.MakeHTTPRequest(auth, url, 'POST', headers, payload);
422
- if (r.Status >= 200 && r.Status < 300) {
423
- const body = r.Body;
424
- const newKey = body['key'] == null ? undefined : String(body['key']);
425
- return this.BuildCreatedResult(newKey, r.Status, ctx.ObjectName);
622
+ /**
623
+ * Returns the local names of the direct (top-level) child elements of an XML fragment, by a
624
+ * depth scan over open/close/self-close tags (so a tag opened at depth 0 is a direct child).
625
+ */
626
+ TopLevelTagNames(xml) {
627
+ const names = [];
628
+ let depth = 0;
629
+ const tokenRe = /<\/?([\w.-]+)\b[^>]*?(\/)?>/g;
630
+ let t;
631
+ while ((t = tokenRe.exec(xml)) !== null) {
632
+ const full = t[0];
633
+ const name = t[1];
634
+ const selfClose = t[2] === '/';
635
+ const isClose = full.startsWith('</');
636
+ if (isClose) {
637
+ depth--;
638
+ continue;
639
+ }
640
+ if (depth === 0)
641
+ names.push(name);
642
+ if (!selfClose)
643
+ depth++;
426
644
  }
427
- return { Success: false, ExternalID: '', StatusCode: r.Status, ErrorMessage: `Create failed: ${r.Status}` };
645
+ return names;
428
646
  }
429
- async UpdateRecord(ctx) {
430
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
431
- const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
432
- const url = `${auth.Config.BaseURL}/xWeb/JSON/UpdateFacadeObject`;
433
- const payload = { szObjectName: ctx.ObjectName, szObjectKey: ctx.ExternalID, oRecord: ctx.Attributes };
434
- const r = await this.MakeHTTPRequest(auth, url, 'POST', headers, payload);
435
- if (r.Status >= 200 && r.Status < 300)
436
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: r.Status };
437
- return { Success: false, ExternalID: ctx.ExternalID, StatusCode: r.Status, ErrorMessage: `Update failed: ${r.Status}` };
647
+ ExtractPaginationInfo(_rb, _pt, _cp, _co, _ps) {
648
+ // GetQuery returns the full result set in one call — no protocol-level pagination.
649
+ return { HasMore: false };
438
650
  }
439
- async DeleteRecord(ctx) {
440
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
441
- const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
442
- const url = `${auth.Config.BaseURL}/xWeb/JSON/DeleteFacadeObject`;
443
- const payload = { szObjectName: ctx.ObjectName, szObjectKey: ctx.ExternalID };
444
- const r = await this.MakeHTTPRequest(auth, url, 'POST', headers, payload);
445
- if (r.Status >= 200 && r.Status < 300)
446
- return { Success: true, ExternalID: ctx.ExternalID, StatusCode: r.Status };
447
- return { Success: false, ExternalID: ctx.ExternalID, StatusCode: r.Status, ErrorMessage: `Delete failed: ${r.Status}` };
651
+ GetBaseURL(_ci, auth) {
652
+ return auth.Config.BaseURL;
448
653
  }
449
- BuildHeaders(auth) {
450
- return { 'Authorization': `Bearer ${auth.Token}`, 'Accept': 'application/json', 'User-Agent': 'MemberJunction-Integration/1.0' };
654
+ /** The base-class abstract BuildHeaders — defers to SoapHeaders for the active operation. */
655
+ BuildHeaders(_auth) {
656
+ return { 'Content-Type': 'text/xml; charset=utf-8', 'User-Agent': 'MemberJunction-Integration/1.0' };
451
657
  }
658
+ /** The base-class abstract MakeHTTPRequest — routes through the raw transport. */
452
659
  async MakeHTTPRequest(_auth, url, method, headers, body) {
660
+ return this.MakeRawHTTPRequest(url, method, headers, typeof body === 'string' ? body : (body == null ? undefined : String(body)));
661
+ }
662
+ /**
663
+ * Raw SOAP transport: POSTs the XML envelope, retries on 401 (re-auth)/429/5xx, and returns the
664
+ * response body as text (SOAP is text/xml). Never logs credentials, the token, or PII.
665
+ *
666
+ * `protected` so a unit-test subclass can override this single transport seam to feed canned
667
+ * SOAP responses (no live network, no credentials) — the only seam tests need to mock.
668
+ */
669
+ async MakeRawHTTPRequest(url, method, headers, body) {
453
670
  await this.Throttle();
454
671
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
455
- const c = new AbortController();
456
- const t = setTimeout(() => c.abort(), REQUEST_TIMEOUT_MS);
672
+ const controller = new AbortController();
673
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
457
674
  try {
458
- const opts = { method, headers, signal: c.signal };
459
- if (body && method !== 'GET')
460
- opts.body = JSON.stringify(body);
675
+ const opts = { method, headers, signal: controller.signal };
676
+ if (body !== undefined && method !== 'GET')
677
+ opts.body = body;
461
678
  const response = await fetch(url, opts);
462
- clearTimeout(t);
679
+ clearTimeout(timer);
463
680
  this.lastRequestTime = Date.now();
464
681
  if (response.status === 401 && attempt === 0) {
465
682
  this.tokenCache = null;
466
683
  continue;
467
684
  }
468
- if (response.status === 429) {
469
- await this.Sleep(Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 60_000));
685
+ if (response.status === 429 && attempt < MAX_RETRIES) {
686
+ await this.Sleep(this.RetryAfterMs(response, attempt));
470
687
  continue;
471
688
  }
472
689
  if (response.status >= 500 && attempt < MAX_RETRIES) {
473
690
  await this.Sleep(Math.min(1000 * Math.pow(2, attempt), 30_000));
474
691
  continue;
475
692
  }
476
- const rb = await (response.headers.get('content-type')?.includes('json') ? response.json() : response.text());
693
+ const text = await response.text();
477
694
  const h = {};
478
695
  response.headers.forEach((v, k) => { h[k.toLowerCase()] = v; });
479
- return { Status: response.status, Body: rb, Headers: h };
696
+ return { Status: response.status, Body: text, Headers: h };
480
697
  }
481
698
  catch (e) {
482
- clearTimeout(t);
699
+ clearTimeout(timer);
483
700
  if (e instanceof Error && e.name === 'AbortError')
484
701
  throw new Error(`NetForum request timed out: ${url}`);
485
- throw e;
702
+ if (attempt >= MAX_RETRIES)
703
+ throw e;
704
+ await this.Sleep(Math.min(1000 * Math.pow(2, attempt), 30_000));
486
705
  }
487
706
  }
488
707
  throw new Error(`NetForum request failed after ${MAX_RETRIES} retries: ${url}`);
489
708
  }
490
- async Throttle() { const e = Date.now() - this.lastRequestTime; if (e < MIN_REQUEST_INTERVAL_MS)
491
- await this.Sleep(MIN_REQUEST_INTERVAL_MS - e); }
709
+ RetryAfterMs(response, attempt) {
710
+ const ra = response.headers.get('retry-after');
711
+ if (ra) {
712
+ const secs = Number(ra);
713
+ if (!Number.isNaN(secs))
714
+ return Math.min(secs * 1000, 60_000);
715
+ }
716
+ return Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
717
+ }
718
+ ExtractRetryAfterMs(error) {
719
+ if (error && typeof error === 'object' && 'Headers' in error) {
720
+ const headers = error.Headers;
721
+ const ra = headers?.['retry-after'];
722
+ if (ra) {
723
+ const secs = Number(ra);
724
+ if (!Number.isNaN(secs))
725
+ return secs * 1000;
726
+ }
727
+ }
728
+ return undefined;
729
+ }
730
+ async Throttle() {
731
+ const elapsed = Date.now() - this.lastRequestTime;
732
+ if (elapsed < MIN_REQUEST_INTERVAL_MS)
733
+ await this.Sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
734
+ }
492
735
  Sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
493
- GetDefaultFieldMappings(objectName) {
494
- const obj = NF_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
495
- if (!obj)
496
- return [];
497
- return obj.Fields.map(f => ({ SourceFieldName: f.Name, DestinationFieldName: f.Name, IsKeyField: f.IsPrimaryKey }));
736
+ ParseObjectConfig(obj) {
737
+ this.LastIntegrationID = obj.IntegrationID ?? this.LastIntegrationID;
738
+ if (!obj.Configuration)
739
+ return {};
740
+ try {
741
+ return JSON.parse(obj.Configuration);
742
+ }
743
+ catch {
744
+ return {};
745
+ }
746
+ }
747
+ SoapEndpoint(cfg) {
748
+ return cfg.soapEndpoint ?? DEFAULT_SOAP_PATH;
749
+ }
750
+ PrimaryKeyFieldName(obj) {
751
+ const fields = this.GetCachedFields(obj.ID);
752
+ const pk = fields.find(f => f.IsPrimaryKey);
753
+ return pk?.Name;
754
+ }
755
+ /** Lowercased names of the IO's declared primary-key fields, read from the cached IOF entities. */
756
+ DeclaredPrimaryKeyNames(ci, objectName) {
757
+ try {
758
+ const obj = this.GetCachedObject(ci.IntegrationID, objectName);
759
+ return new Set(this.GetCachedFields(obj.ID).filter(f => f.IsPrimaryKey).map(f => f.Name.toLowerCase()));
760
+ }
761
+ catch {
762
+ return new Set();
763
+ }
764
+ }
765
+ AsText(body) {
766
+ if (typeof body === 'string')
767
+ return body;
768
+ if (body == null)
769
+ return '';
770
+ return String(body);
498
771
  }
499
772
  };
500
773
  NetForumConnector = __decorate([