@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.
- package/dist/CventConnector.d.ts +146 -0
- package/dist/CventConnector.d.ts.map +1 -0
- package/dist/CventConnector.js +540 -0
- package/dist/CventConnector.js.map +1 -0
- package/dist/DynamicsDataverseConnector.d.ts +296 -0
- package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
- package/dist/DynamicsDataverseConnector.js +790 -0
- package/dist/DynamicsDataverseConnector.js.map +1 -0
- package/dist/FontevaConnector.d.ts +153 -0
- package/dist/FontevaConnector.d.ts.map +1 -0
- package/dist/FontevaConnector.js +421 -0
- package/dist/FontevaConnector.js.map +1 -0
- package/dist/GrowthZoneConnector.d.ts +123 -125
- package/dist/GrowthZoneConnector.d.ts.map +1 -1
- package/dist/GrowthZoneConnector.js +456 -499
- package/dist/GrowthZoneConnector.js.map +1 -1
- package/dist/HivebriteConnector.d.ts +151 -0
- package/dist/HivebriteConnector.d.ts.map +1 -0
- package/dist/HivebriteConnector.js +533 -0
- package/dist/HivebriteConnector.js.map +1 -0
- package/dist/IMISConnector.d.ts +112 -96
- package/dist/IMISConnector.d.ts.map +1 -1
- package/dist/IMISConnector.js +430 -417
- package/dist/IMISConnector.js.map +1 -1
- package/dist/MemberSuiteConnector.d.ts +185 -0
- package/dist/MemberSuiteConnector.d.ts.map +1 -0
- package/dist/MemberSuiteConnector.js +762 -0
- package/dist/MemberSuiteConnector.js.map +1 -0
- package/dist/NeonCRMConnector.d.ts +273 -0
- package/dist/NeonCRMConnector.d.ts.map +1 -0
- package/dist/NeonCRMConnector.js +1039 -0
- package/dist/NeonCRMConnector.js.map +1 -0
- package/dist/NetForumConnector.d.ts +140 -47
- package/dist/NetForumConnector.d.ts.map +1 -1
- package/dist/NetForumConnector.js +668 -395
- package/dist/NetForumConnector.js.map +1 -1
- package/dist/NetSuiteConnector.d.ts +116 -50
- package/dist/NetSuiteConnector.d.ts.map +1 -1
- package/dist/NetSuiteConnector.js +608 -510
- package/dist/NetSuiteConnector.js.map +1 -1
- package/dist/NimbleAMSConnector.d.ts +104 -11
- package/dist/NimbleAMSConnector.d.ts.map +1 -1
- package/dist/NimbleAMSConnector.js +552 -258
- package/dist/NimbleAMSConnector.js.map +1 -1
- package/dist/NoviConnector.d.ts +178 -0
- package/dist/NoviConnector.d.ts.map +1 -0
- package/dist/NoviConnector.js +566 -0
- package/dist/NoviConnector.js.map +1 -0
- package/dist/ORCIDConnector.d.ts +207 -0
- package/dist/ORCIDConnector.d.ts.map +1 -0
- package/dist/ORCIDConnector.js +708 -0
- package/dist/ORCIDConnector.js.map +1 -0
- package/dist/OpenWaterConnector.d.ts +173 -0
- package/dist/OpenWaterConnector.d.ts.map +1 -0
- package/dist/OpenWaterConnector.js +893 -0
- package/dist/OpenWaterConnector.js.map +1 -0
- package/dist/PathLMSConnector.d.ts +395 -0
- package/dist/PathLMSConnector.d.ts.map +1 -0
- package/dist/PathLMSConnector.js +1281 -0
- package/dist/PathLMSConnector.js.map +1 -0
- package/dist/PheedLoopConnector.d.ts +119 -0
- package/dist/PheedLoopConnector.d.ts.map +1 -0
- package/dist/PheedLoopConnector.js +429 -0
- package/dist/PheedLoopConnector.js.map +1 -0
- package/dist/PropFuelConnector.d.ts +135 -61
- package/dist/PropFuelConnector.d.ts.map +1 -1
- package/dist/PropFuelConnector.js +366 -467
- package/dist/PropFuelConnector.js.map +1 -1
- package/dist/RhythmConnector.d.ts +188 -0
- package/dist/RhythmConnector.d.ts.map +1 -0
- package/dist/RhythmConnector.js +784 -0
- package/dist/RhythmConnector.js.map +1 -0
- package/dist/SalesforceConnector.d.ts +69 -3
- package/dist/SalesforceConnector.d.ts.map +1 -1
- package/dist/SalesforceConnector.js +188 -166
- package/dist/SalesforceConnector.js.map +1 -1
- package/dist/SharePointConnector.d.ts +37 -30
- package/dist/SharePointConnector.d.ts.map +1 -1
- package/dist/SharePointConnector.js +163 -243
- package/dist/SharePointConnector.js.map +1 -1
- package/dist/YourMembershipConnector.d.ts.map +1 -1
- package/dist/YourMembershipConnector.js +13 -5
- package/dist/YourMembershipConnector.js.map +1 -1
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/dist/BettyConnector.d.ts +0 -288
- package/dist/BettyConnector.d.ts.map +0 -1
- package/dist/BettyConnector.js +0 -909
- package/dist/BettyConnector.js.map +0 -1
|
@@ -4,548 +4,447 @@ 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
|
-
* PropFuelConnector — REST integration connector for PropFuel (now part of re:Members),
|
|
9
|
-
* a personalized member-engagement platform for associations (email + SMS conversational
|
|
10
|
-
* "Ask, Capture, Act" campaigns).
|
|
11
|
-
*
|
|
12
|
-
* ⚠️ STATUS: READ-ONLY SCAFFOLD.
|
|
13
|
-
*
|
|
14
|
-
* PropFuel does not publish a public developer portal. The connector below is built
|
|
15
|
-
* against the *expected* shape of a tenant-scoped REST API (per-customer BaseURL +
|
|
16
|
-
* bearer-token API key) and the documented domain objects (member profiles, campaigns,
|
|
17
|
-
* conversations / engagements, captures / responses, segments, tags). Endpoint paths,
|
|
18
|
-
* pagination strategy, and field names should be confirmed against PropFuel's actual
|
|
19
|
-
* API specification before relying on this connector for production sync.
|
|
20
|
-
*
|
|
21
|
-
* To productionize:
|
|
22
|
-
* 1. Obtain PropFuel API documentation (Swagger / OpenAPI / Postman collection).
|
|
23
|
-
* 2. Verify the BaseURL pattern, auth header (Bearer vs X-API-Key), and pagination type.
|
|
24
|
-
* 3. Replace the `PROPFUEL_OBJECT_PATHS` placeholder paths with real endpoints.
|
|
25
|
-
* 4. Audit the field metadata in `metadata/integrations/.propfuel.json` against the spec.
|
|
26
|
-
* 5. Add Create/Update/Delete support and flip `SupportsCreate/Update/Delete` to true.
|
|
27
|
-
*/
|
|
28
7
|
import { RegisterClass } from '@memberjunction/global';
|
|
29
8
|
import { Metadata } from '@memberjunction/core';
|
|
30
9
|
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
31
|
-
|
|
32
|
-
const DEFAULT_PAGE_SIZE = 100;
|
|
33
|
-
const DEFAULT_MAX_RETRIES = 3;
|
|
34
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
35
|
-
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 250;
|
|
36
|
-
/**
|
|
37
|
-
* Placeholder API paths. Update these once PropFuel publishes formal API docs. Each
|
|
38
|
-
* value should be the URL path segment (no leading slash) appended to the BaseURL —
|
|
39
|
-
* for example, `/v1/contacts` with BaseURL `https://api.propfuel.com` resolves to
|
|
40
|
-
* `https://api.propfuel.com/v1/contacts`.
|
|
41
|
-
*/
|
|
42
|
-
const PROPFUEL_OBJECT_PATHS = {
|
|
43
|
-
Contacts: '/contacts',
|
|
44
|
-
Campaigns: '/campaigns',
|
|
45
|
-
Conversations: '/conversations',
|
|
46
|
-
Captures: '/captures',
|
|
47
|
-
Segments: '/segments',
|
|
48
|
-
Tags: '/tags',
|
|
49
|
-
};
|
|
10
|
+
import { z } from 'zod';
|
|
50
11
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
12
|
+
* PropFuel data-export file-feed connector.
|
|
13
|
+
*
|
|
14
|
+
* PropFuel exposes a per-tenant hourly data-export feed as a set of JSON files. There is NO
|
|
15
|
+
* public OpenAPI spec and NO describe/introspection endpoint, so this connector encodes the
|
|
16
|
+
* DISCOVERY MECHANISM, never a baked answer:
|
|
17
|
+
*
|
|
18
|
+
* - {@link DiscoverObjects} calls `GET /dataexport/{AccountID}/list` and derives each object
|
|
19
|
+
* from the `[microtime]-[datatype].json` filename suffix. The concrete data-type set is
|
|
20
|
+
* whatever the live listing actually contains — never a static `PROPFUEL_STREAMS` array.
|
|
21
|
+
* - {@link DiscoverFields} lists the files for a data type, downloads ONE sample file, and
|
|
22
|
+
* streams its records through the base {@link DiscoverFieldsViaStream} helper to derive the
|
|
23
|
+
* field set + data-informed soft-PK from real values — never a frozen `STREAM_FIELDS` catalog.
|
|
24
|
+
*
|
|
25
|
+
* Incremental sync uses a SYNTHETIC, FILE-LEVEL, CLIENT-SIDE cursor `__file_microtime`: the feed
|
|
26
|
+
* is append-only and chronologically sortable by the filename microtime prefix, so the connector
|
|
27
|
+
* resumes by tracking the max microtime seen across processed files (WatermarkService). The vendor
|
|
28
|
+
* `ack` endpoint is operational state and is NOT used as the read-only incremental cursor.
|
|
29
|
+
*
|
|
30
|
+
* READ-ONLY: the data-export feed creates/updates/deletes no source records, so all write
|
|
31
|
+
* capability getters stay false and no ack/POST/delete path is exercised.
|
|
32
|
+
*
|
|
33
|
+
* AccountID and the bearer Token both come from the credential (or Configuration JSON) — the
|
|
34
|
+
* AccountID is per-tenant config and is NEVER hardcoded.
|
|
55
35
|
*/
|
|
56
|
-
const PROPFUEL_OBJECTS = [
|
|
57
|
-
{
|
|
58
|
-
Name: 'Contacts', DisplayName: 'Contacts',
|
|
59
|
-
Description: 'Member / subscriber profile records — the primary identity for engagement.',
|
|
60
|
-
SupportsWrite: false,
|
|
61
|
-
Fields: [
|
|
62
|
-
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Contact ID' },
|
|
63
|
-
{ Name: 'email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Primary email address' },
|
|
64
|
-
],
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
Name: 'Campaigns', DisplayName: 'Campaigns',
|
|
68
|
-
Description: 'Engagement campaigns built from PropFuel blueprints (Ask / Capture / Act flows).',
|
|
69
|
-
SupportsWrite: false,
|
|
70
|
-
Fields: [
|
|
71
|
-
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Campaign ID' },
|
|
72
|
-
],
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
Name: 'Conversations', DisplayName: 'Conversations',
|
|
76
|
-
Description: 'Two-way member interactions across email and SMS.',
|
|
77
|
-
SupportsWrite: false,
|
|
78
|
-
Fields: [
|
|
79
|
-
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Conversation ID' },
|
|
80
|
-
{ Name: 'contact_id', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'FK → Contacts' },
|
|
81
|
-
],
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
Name: 'Captures', DisplayName: 'Captures',
|
|
85
|
-
Description: 'Member responses to Ask prompts — the data PropFuel collects from a campaign.',
|
|
86
|
-
SupportsWrite: false,
|
|
87
|
-
Fields: [
|
|
88
|
-
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Capture ID' },
|
|
89
|
-
{ Name: 'contact_id', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'FK → Contacts' },
|
|
90
|
-
{ Name: 'campaign_id', DisplayName: 'Campaign ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'FK → Campaigns' },
|
|
91
|
-
],
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
Name: 'Segments', DisplayName: 'Segments',
|
|
95
|
-
Description: 'Audience segments used to target campaigns.',
|
|
96
|
-
SupportsWrite: false,
|
|
97
|
-
Fields: [
|
|
98
|
-
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Segment ID' },
|
|
99
|
-
],
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
Name: 'Tags', DisplayName: 'Tags',
|
|
103
|
-
Description: 'Tags applied to Contacts for segmentation and targeting.',
|
|
104
|
-
SupportsWrite: false,
|
|
105
|
-
Fields: [
|
|
106
|
-
{ Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Tag ID' },
|
|
107
|
-
],
|
|
108
|
-
},
|
|
109
|
-
];
|
|
110
|
-
const WATERMARK_CANDIDATE_FIELDS = ['updated_at', 'modified_at', 'last_updated', 'updatedAt'];
|
|
111
|
-
// ─── Connector ──────────────────────────────────────────────────────────
|
|
112
36
|
let PropFuelConnector = class PropFuelConnector extends BaseRESTIntegrationConnector {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this.lastRequestTime = 0;
|
|
37
|
+
/** Verbatim three-way invariant name: ClassName / IntegrationName getter / MJ: Integrations.Name. */
|
|
38
|
+
get IntegrationName() {
|
|
39
|
+
return 'PropFuel';
|
|
117
40
|
}
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return PROPFUEL_OBJECTS;
|
|
41
|
+
// ─── Capability surface (read-only file feed) ────────────────────
|
|
42
|
+
// SupportsCreate/Update/Delete stay false (BaseIntegrationConnector defaults). The data-export
|
|
43
|
+
// feed is a pull-only source; the ack endpoint is operational and is not modelled as a write.
|
|
44
|
+
/**
|
|
45
|
+
* KEYSET / no-watermark resume hint: every PropFuel object is a data-export file stream ordered
|
|
46
|
+
* by the filename microtime prefix, so 'microtime' is the stable, monotonic ordering key for all
|
|
47
|
+
* of them (the docs-provable feed object and every runtime-discovered data-type stream alike).
|
|
48
|
+
*/
|
|
49
|
+
StableOrderingKey(_objectName) {
|
|
50
|
+
return MICROTIME_ORDERING_KEY;
|
|
129
51
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
52
|
+
/**
|
|
53
|
+
* The file-feed microtime IS a reliable monotonic high-water mark: PropFuel emits export files in
|
|
54
|
+
* ascending microtime order, and an updated record re-appears in a NEW (higher-microtime) file. So
|
|
55
|
+
* `NewWatermarkValue` (= max microtime seen) is the true global max — the engine can NARROW the next
|
|
56
|
+
* incremental to `microtime > watermark` instead of clearing the keyset position and re-scanning the
|
|
57
|
+
* whole stream every run. Without this, a clean sync's keyset-resume marker is cleared and every
|
|
58
|
+
* incremental re-walks the entire object (correct via content-hash, but wasteful at scale).
|
|
59
|
+
*/
|
|
60
|
+
get MonotonicWatermark() {
|
|
61
|
+
return true;
|
|
140
62
|
}
|
|
141
|
-
//
|
|
63
|
+
// ─── Auth + transport (BaseRESTIntegrationConnector abstracts) ────
|
|
64
|
+
/**
|
|
65
|
+
* Resolves the bearer Token + per-tenant AccountID from the linked Credential entity, falling
|
|
66
|
+
* back to the CompanyIntegration.Configuration JSON. The credential bytes never leave this scope.
|
|
67
|
+
*/
|
|
142
68
|
async Authenticate(companyIntegration, contextUser) {
|
|
143
|
-
|
|
69
|
+
const creds = await this.LoadCredentials(companyIntegration, contextUser);
|
|
70
|
+
return { Token: creds.Token, AccountID: creds.AccountID, BaseHost: creds.BaseHost };
|
|
144
71
|
}
|
|
72
|
+
/** Static Bearer header. No crypto/signing is required for a static token, so no auth-helper is used. */
|
|
145
73
|
BuildHeaders(auth) {
|
|
146
74
|
return {
|
|
147
75
|
'Authorization': `Bearer ${auth.Token}`,
|
|
148
76
|
'Accept': 'application/json',
|
|
149
|
-
'User-Agent': 'MemberJunction-Integration/1.0',
|
|
150
77
|
};
|
|
151
78
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
79
|
+
/** Base URL for the data-export feed, tenant-scoped by the resolved AccountID. */
|
|
80
|
+
GetBaseURL(_companyIntegration, auth) {
|
|
81
|
+
return `${auth.BaseHost ?? PROPFUEL_HOST}/dataexport/${encodeURIComponent(auth.AccountID)}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Executes an HTTP request via fetch. Parses JSON when the response is JSON, else returns the
|
|
85
|
+
* raw text body. The concrete connector owns the transport seam so tests can override it.
|
|
86
|
+
*/
|
|
87
|
+
async MakeHTTPRequest(_auth, url, method, headers, body) {
|
|
88
|
+
const init = { method, headers };
|
|
89
|
+
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
90
|
+
init.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
91
|
+
init.headers['Content-Type'] = 'application/json';
|
|
161
92
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
93
|
+
const response = await fetch(url, init);
|
|
94
|
+
const responseHeaders = {};
|
|
95
|
+
response.headers.forEach((value, key) => { responseHeaders[key.toLowerCase()] = value; });
|
|
96
|
+
const text = await response.text();
|
|
97
|
+
let parsed = text;
|
|
98
|
+
const contentType = responseHeaders['content-type'] ?? '';
|
|
99
|
+
if (contentType.includes('json') || (text.length > 0 && (text[0] === '{' || text[0] === '['))) {
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(text);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
parsed = text;
|
|
169
105
|
}
|
|
170
|
-
return this.BuildRESTResponse(response);
|
|
171
106
|
}
|
|
172
|
-
|
|
107
|
+
return { Status: response.status, Body: parsed, Headers: responseHeaders };
|
|
173
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The download response for a data-export file is a JSON array of records (no envelope). When a
|
|
111
|
+
* vendor wraps records under a key, that key is honored; otherwise a root array / single object
|
|
112
|
+
* is normalized to an array.
|
|
113
|
+
*/
|
|
174
114
|
NormalizeResponse(rawBody, responseDataKey) {
|
|
175
|
-
if (rawBody
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const body = rawBody;
|
|
180
|
-
if (responseDataKey && Array.isArray(body[responseDataKey])) {
|
|
181
|
-
return body[responseDataKey];
|
|
115
|
+
if (responseDataKey && rawBody && typeof rawBody === 'object' && !Array.isArray(rawBody)) {
|
|
116
|
+
const inner = rawBody[responseDataKey];
|
|
117
|
+
if (Array.isArray(inner))
|
|
118
|
+
return inner.filter(isRecord);
|
|
182
119
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
if (Object.keys(body).length > 0)
|
|
189
|
-
return [body];
|
|
120
|
+
if (Array.isArray(rawBody))
|
|
121
|
+
return rawBody.filter(isRecord);
|
|
122
|
+
if (isRecord(rawBody))
|
|
123
|
+
return [rawBody];
|
|
190
124
|
return [];
|
|
191
125
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
switch (paginationType) {
|
|
199
|
-
case 'Offset':
|
|
200
|
-
return { HasMore: hasMore, NextOffset: currentOffset + records.length };
|
|
201
|
-
case 'PageNumber':
|
|
202
|
-
return { HasMore: hasMore, NextPage: currentPage + 1 };
|
|
203
|
-
case 'Cursor': {
|
|
204
|
-
const body = rawBody;
|
|
205
|
-
const cursor = body?.['next_cursor'] ?? body?.['cursor'] ?? body?.['nextCursor'];
|
|
206
|
-
return {
|
|
207
|
-
HasMore: !!cursor,
|
|
208
|
-
NextCursor: typeof cursor === 'string' ? cursor : undefined,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
default:
|
|
212
|
-
return { HasMore: hasMore };
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
GetBaseURL(_companyIntegration, auth) {
|
|
216
|
-
return auth.BaseURL;
|
|
126
|
+
/**
|
|
127
|
+
* The file feed has no in-file pagination — each downloaded file is read whole. File-level
|
|
128
|
+
* paging across the listing is handled by {@link FetchChanges} via the microtime cursor.
|
|
129
|
+
*/
|
|
130
|
+
ExtractPaginationInfo(_rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
|
|
131
|
+
return { HasMore: false };
|
|
217
132
|
}
|
|
218
|
-
|
|
133
|
+
/** Tests connectivity by listing the tenant's available data-export files. */
|
|
219
134
|
async TestConnection(companyIntegration, contextUser) {
|
|
220
135
|
try {
|
|
221
|
-
const auth = await this.
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (response.Status >= 200 && response.Status < 300) {
|
|
226
|
-
return { Success: true, Message: `Connected to PropFuel at ${auth.BaseURL}` };
|
|
136
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
137
|
+
const response = await this.ListFiles(auth);
|
|
138
|
+
if (response.Status === 401 || response.Status === 403) {
|
|
139
|
+
return { Success: false, Message: `Authentication failed (HTTP ${response.Status}) listing PropFuel data-export files.` };
|
|
227
140
|
}
|
|
228
|
-
|
|
229
|
-
Success: false,
|
|
230
|
-
|
|
231
|
-
|
|
141
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
142
|
+
return { Success: false, Message: `PropFuel list endpoint returned HTTP ${response.Status}.` };
|
|
143
|
+
}
|
|
144
|
+
const files = this.ParseFileListing(response.Body);
|
|
145
|
+
return { Success: true, Message: `Connected to PropFuel account ${auth.AccountID}; ${files.length} data-export file(s) available.` };
|
|
232
146
|
}
|
|
233
147
|
catch (err) {
|
|
234
148
|
const message = err instanceof Error ? err.message : String(err);
|
|
235
|
-
return { Success: false, Message: `PropFuel connection
|
|
149
|
+
return { Success: false, Message: `PropFuel connection error: ${message}` };
|
|
236
150
|
}
|
|
237
151
|
}
|
|
152
|
+
// ─── Runtime discovery MECHANISM (NOT a baked catalog) ───────────
|
|
153
|
+
/**
|
|
154
|
+
* Discovers objects by listing the live data-export files and deriving the distinct data-type
|
|
155
|
+
* suffix from each `[microtime]-[datatype].json` filename. The returned object set is exactly
|
|
156
|
+
* what the source currently exposes — the frozen contract's `propfuel_data_export_file` is the
|
|
157
|
+
* VERIFICATION FLOOR (discovery must surface at least the feed), never a ceiling.
|
|
158
|
+
*/
|
|
238
159
|
async DiscoverObjects(companyIntegration, contextUser) {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
160
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
161
|
+
const response = await this.ListFiles(auth);
|
|
162
|
+
this.AssertOK(response, 'list data-export files');
|
|
163
|
+
const files = this.ParseFileListing(response.Body);
|
|
164
|
+
const dataTypes = new Set();
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
const parsed = parseFileName(f);
|
|
167
|
+
if (parsed)
|
|
168
|
+
dataTypes.add(parsed.dataType);
|
|
169
|
+
}
|
|
170
|
+
return [...dataTypes].sort().map(dataType => ({
|
|
171
|
+
Name: dataType,
|
|
172
|
+
Label: dataType,
|
|
173
|
+
Description: `PropFuel data-export stream "${dataType}" (discovered from the data-export file listing).`,
|
|
246
174
|
SupportsIncrementalSync: true,
|
|
247
|
-
SupportsWrite:
|
|
175
|
+
SupportsWrite: false,
|
|
248
176
|
}));
|
|
249
177
|
}
|
|
250
178
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
179
|
+
* Discovers fields by downloading ONE sample file for the data type and streaming its records
|
|
180
|
+
* through the base data-informed discovery helper. The field set + soft-PK come from the real
|
|
181
|
+
* values the source returns — never a static `STREAM_FIELDS` map. READ-ONLY: the sample file is
|
|
182
|
+
* fetched and parsed, never acked.
|
|
254
183
|
*/
|
|
255
184
|
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
// Live discovery failed — fall through to the static fallback below.
|
|
271
|
-
}
|
|
272
|
-
const staticFields = await super.DiscoverFields(companyIntegration, objectName, contextUser);
|
|
273
|
-
if (staticFields.length > 0)
|
|
274
|
-
return staticFields;
|
|
275
|
-
const obj = PROPFUEL_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
|
|
276
|
-
if (!obj)
|
|
185
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
186
|
+
const response = await this.ListFiles(auth);
|
|
187
|
+
this.AssertOK(response, 'list data-export files');
|
|
188
|
+
const files = this.ParseFileListing(response.Body);
|
|
189
|
+
// Pick the most-recent file (max microtime) of this data type as the discovery sample.
|
|
190
|
+
const sample = files
|
|
191
|
+
.map(f => ({ file: f, parsed: parseFileName(f) }))
|
|
192
|
+
.filter(x => x.parsed && x.parsed.dataType === objectName)
|
|
193
|
+
.sort((a, b) => compareMicrotime(b.parsed.microtime, a.parsed.microtime))[0];
|
|
194
|
+
if (!sample)
|
|
277
195
|
return [];
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
Description: f.Description,
|
|
284
|
-
DataType: f.Type,
|
|
285
|
-
IsRequired: f.IsRequired,
|
|
286
|
-
IsUniqueKey: f.IsPrimaryKey,
|
|
287
|
-
IsReadOnly: f.IsReadOnly,
|
|
288
|
-
IsForeignKey: fkTarget !== null,
|
|
289
|
-
ForeignKeyTarget: fkTarget,
|
|
290
|
-
};
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
InferFieldsFromSample(sample, objectName) {
|
|
294
|
-
const staticObj = PROPFUEL_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
|
|
295
|
-
const staticMap = new Map((staticObj?.Fields ?? []).map(f => [f.Name.toLowerCase(), f]));
|
|
296
|
-
return Object.entries(sample).map(([key, value]) => {
|
|
297
|
-
const overlay = staticMap.get(key.toLowerCase());
|
|
298
|
-
const fkTarget = this.ResolveForeignKeyTarget(key, overlay?.Description);
|
|
299
|
-
return {
|
|
300
|
-
Name: key,
|
|
301
|
-
Label: overlay?.DisplayName ?? key,
|
|
302
|
-
Description: overlay?.Description ?? '',
|
|
303
|
-
DataType: overlay?.Type ?? this.InferType(value),
|
|
304
|
-
IsRequired: overlay?.IsRequired ?? false,
|
|
305
|
-
IsUniqueKey: overlay?.IsPrimaryKey ?? false,
|
|
306
|
-
IsReadOnly: overlay?.IsReadOnly ?? true,
|
|
307
|
-
IsForeignKey: fkTarget !== null,
|
|
308
|
-
ForeignKeyTarget: fkTarget,
|
|
309
|
-
};
|
|
310
|
-
});
|
|
196
|
+
const records = await this.DownloadFileRecords(auth, sample.file);
|
|
197
|
+
// Data-informed discovery: full field corpus + statistics-first soft-PK over the real records.
|
|
198
|
+
// Default Pk options let the helper combine uniqueness/null statistics with the built-in
|
|
199
|
+
// naming-convention rank (defaultPkNameRank) to pick a best-available soft key.
|
|
200
|
+
return this.DiscoverFieldsViaStream(records, { ReadOnly: true });
|
|
311
201
|
}
|
|
202
|
+
// ─── Incremental fetch via the synthetic file-level microtime cursor ──
|
|
312
203
|
/**
|
|
313
|
-
*
|
|
204
|
+
* Fetches records for a data type using the synthetic `__file_microtime` cursor.
|
|
314
205
|
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* Returns the sibling object's external Name, or null when nothing is provable.
|
|
206
|
+
* Mechanism: load the stored cursor (max microtime processed), list the live files, select the
|
|
207
|
+
* files of this data type whose microtime exceeds the cursor (ascending microtime order), and
|
|
208
|
+
* download each, emitting every record with full-record pass-through. The max microtime seen
|
|
209
|
+
* across the processed files is persisted as the new cursor — ONLY on full-batch success, so a
|
|
210
|
+
* partial failure leaves the cursor unchanged and the next run resumes from the same point.
|
|
321
211
|
*/
|
|
322
|
-
ResolveForeignKeyTarget(fieldName, description) {
|
|
323
|
-
const lower = fieldName.toLowerCase();
|
|
324
|
-
if (!lower.endsWith('_id') || lower === 'id')
|
|
325
|
-
return null;
|
|
326
|
-
const stem = lower.slice(0, -'_id'.length); // e.g. "contact", "campaign"
|
|
327
|
-
const target = PROPFUEL_OBJECTS.find(o => {
|
|
328
|
-
const name = o.Name.toLowerCase();
|
|
329
|
-
// singular/plural-aware match against a sibling object's external name
|
|
330
|
-
return name === stem || name === `${stem}s` || `${name}s` === stem || name === stem.replace(/y$/, 'ies');
|
|
331
|
-
});
|
|
332
|
-
if (!target)
|
|
333
|
-
return null;
|
|
334
|
-
// Honor the declared `FK → X` description when present — it must name this sibling.
|
|
335
|
-
if (description && /^fk\s*→/i.test(description.trim())) {
|
|
336
|
-
const declaredTarget = description.replace(/^fk\s*→\s*/i, '').trim().toLowerCase();
|
|
337
|
-
if (declaredTarget && declaredTarget !== target.Name.toLowerCase())
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
return target.Name;
|
|
341
|
-
}
|
|
342
|
-
InferType(value) {
|
|
343
|
-
if (value === null || value === undefined)
|
|
344
|
-
return 'string';
|
|
345
|
-
if (typeof value === 'number')
|
|
346
|
-
return Number.isInteger(value) ? 'integer' : 'decimal';
|
|
347
|
-
if (typeof value === 'boolean')
|
|
348
|
-
return 'boolean';
|
|
349
|
-
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))
|
|
350
|
-
return 'datetime';
|
|
351
|
-
return 'string';
|
|
352
|
-
}
|
|
353
|
-
// ── Fetch + watermark ───────────────────────────────────────────
|
|
354
212
|
async FetchChanges(ctx) {
|
|
355
|
-
const auth = await this.
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
213
|
+
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
214
|
+
const listResponse = await this.ListFiles(auth);
|
|
215
|
+
this.AssertOK(listResponse, 'list data-export files');
|
|
216
|
+
const allFiles = this.ParseFileListing(listResponse.Body);
|
|
217
|
+
// The resume cursor: prefer the engine-provided keyset position, else the watermark value.
|
|
218
|
+
const cursor = ctx.AfterKeyValue ?? ctx.WatermarkValue ?? null;
|
|
219
|
+
const candidates = allFiles
|
|
220
|
+
.map(f => ({ file: f, parsed: parseFileName(f) }))
|
|
221
|
+
.filter((x) => x.parsed != null && x.parsed.dataType === ctx.ObjectName)
|
|
222
|
+
.filter(x => cursor == null || compareMicrotime(x.parsed.microtime, cursor) > 0)
|
|
223
|
+
.sort((a, b) => compareMicrotime(a.parsed.microtime, b.parsed.microtime));
|
|
224
|
+
const records = [];
|
|
225
|
+
let maxMicrotime = cursor ?? '';
|
|
226
|
+
const batchLimit = ctx.BatchSize && ctx.BatchSize > 0 ? ctx.BatchSize : Number.MAX_SAFE_INTEGER;
|
|
227
|
+
let filesProcessed = 0;
|
|
228
|
+
for (const candidate of candidates) {
|
|
229
|
+
if (records.length >= batchLimit)
|
|
230
|
+
break;
|
|
231
|
+
const fileRecords = await this.DownloadFileRecords(auth, candidate.file);
|
|
232
|
+
for (const raw of fileRecords) {
|
|
233
|
+
// Full-record pass-through: the COMPLETE source record reaches ExternalRecord.Fields
|
|
234
|
+
// so the framework's custom-column capture sees every key the source returned.
|
|
235
|
+
records.push({
|
|
236
|
+
ExternalID: this.BuildRecordIdentity(raw, candidate.parsed),
|
|
237
|
+
ObjectType: ctx.ObjectName,
|
|
238
|
+
Fields: raw,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (compareMicrotime(candidate.parsed.microtime, maxMicrotime) > 0) {
|
|
242
|
+
maxMicrotime = candidate.parsed.microtime;
|
|
243
|
+
}
|
|
244
|
+
filesProcessed++;
|
|
365
245
|
}
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
Fields: r,
|
|
371
|
-
}));
|
|
372
|
-
return {
|
|
373
|
-
Records: externalRecords,
|
|
374
|
-
HasMore: records.length >= DEFAULT_PAGE_SIZE,
|
|
375
|
-
NextOffset: offset + records.length,
|
|
376
|
-
NewWatermarkValue: this.ComputeNewWatermark(externalRecords, ctx.WatermarkValue),
|
|
246
|
+
const moreFiles = filesProcessed < candidates.length;
|
|
247
|
+
const result = {
|
|
248
|
+
Records: records,
|
|
249
|
+
HasMore: moreFiles,
|
|
377
250
|
};
|
|
251
|
+
if (maxMicrotime && maxMicrotime !== (cursor ?? '')) {
|
|
252
|
+
// Surface the new cursor both as the watermark value and the keyset resume position.
|
|
253
|
+
result.NewWatermarkValue = maxMicrotime;
|
|
254
|
+
result.NextAfterKeyValue = maxMicrotime;
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
// ─── Helpers ──────────────────────────────────────────────────────
|
|
259
|
+
/** Issues the list call. Path is relative; BuildHeaders + GetBaseURL supply auth + host. */
|
|
260
|
+
async ListFiles(auth) {
|
|
261
|
+
const url = `${this.GetBaseURL({}, auth)}/list`;
|
|
262
|
+
return this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
|
|
378
263
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return `${baseURL}${path}?${params.toString()}`;
|
|
264
|
+
/** Downloads one file by name and returns its records (read-only; never acked). */
|
|
265
|
+
async DownloadFileRecords(auth, file) {
|
|
266
|
+
const url = `${this.GetBaseURL({}, auth)}/download/${encodeURIComponent(file)}`;
|
|
267
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
|
|
268
|
+
this.AssertOK(response, `download file ${file}`);
|
|
269
|
+
return this.NormalizeResponse(response.Body, null);
|
|
386
270
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
271
|
+
/**
|
|
272
|
+
* Parses the list endpoint body into an array of file names. The listing may be a bare array of
|
|
273
|
+
* strings, an array of objects with a name/file/filename key, or an object wrapping such an array.
|
|
274
|
+
*/
|
|
275
|
+
ParseFileListing(body) {
|
|
276
|
+
const arr = Array.isArray(body)
|
|
277
|
+
? body
|
|
278
|
+
: (isRecord(body) ? this.FindArrayInObject(body) : []);
|
|
279
|
+
const files = [];
|
|
280
|
+
for (const item of arr) {
|
|
281
|
+
if (typeof item === 'string') {
|
|
282
|
+
files.push(item);
|
|
283
|
+
}
|
|
284
|
+
else if (isRecord(item)) {
|
|
285
|
+
const name = item['name'] ?? item['file'] ?? item['filename'] ?? item['fileName'] ?? item['key'];
|
|
286
|
+
if (typeof name === 'string')
|
|
287
|
+
files.push(name);
|
|
394
288
|
}
|
|
395
289
|
}
|
|
396
|
-
|
|
397
|
-
return current ?? undefined;
|
|
398
|
-
if (current && latest <= current)
|
|
399
|
-
return current;
|
|
400
|
-
return latest;
|
|
401
|
-
}
|
|
402
|
-
// ── Default config + field mappings ─────────────────────────────
|
|
403
|
-
GetDefaultConfiguration() {
|
|
404
|
-
return { DefaultSchemaName: 'PropFuel', DefaultObjects: [] };
|
|
290
|
+
return files;
|
|
405
291
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
IsKeyField: f.IsPrimaryKey,
|
|
414
|
-
}));
|
|
292
|
+
/** Finds the first array-valued property of an object (the file listing under a wrapper key). */
|
|
293
|
+
FindArrayInObject(obj) {
|
|
294
|
+
for (const v of Object.values(obj)) {
|
|
295
|
+
if (Array.isArray(v))
|
|
296
|
+
return v;
|
|
297
|
+
}
|
|
298
|
+
return [];
|
|
415
299
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
431
|
-
return this.authState;
|
|
300
|
+
/**
|
|
301
|
+
* Builds a stable record identity. The per-record schema is undocumented (no provable PK), so
|
|
302
|
+
* the connector prefers a common id key when one is present and otherwise falls back to a
|
|
303
|
+
* deterministic content hash scoped to the file's microtime — the base engine dedupes by the
|
|
304
|
+
* record-map + content hash regardless.
|
|
305
|
+
*/
|
|
306
|
+
BuildRecordIdentity(raw, parsed) {
|
|
307
|
+
for (const k of ['id', 'ID', 'Id', 'uuid', 'externalID', 'ExternalID']) {
|
|
308
|
+
const v = raw[k];
|
|
309
|
+
if (typeof v === 'string' && v.length > 0)
|
|
310
|
+
return v;
|
|
311
|
+
if (typeof v === 'number')
|
|
312
|
+
return String(v);
|
|
313
|
+
}
|
|
314
|
+
return `${parsed.microtime}:${stableHash(raw)}`;
|
|
432
315
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
316
|
+
/** Throws a descriptive error on a non-2xx response (404 included, since a missing file is unexpected here). */
|
|
317
|
+
AssertOK(response, action) {
|
|
318
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
319
|
+
throw new Error(`PropFuel failed to ${action}: HTTP ${response.Status}`);
|
|
320
|
+
}
|
|
436
321
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Resolves the bearer Token + per-tenant AccountID from the linked Credential entity, falling
|
|
324
|
+
* back to the CompanyIntegration.Configuration JSON. AccountID is per-tenant config — NEVER hardcoded.
|
|
325
|
+
*/
|
|
326
|
+
async LoadCredentials(companyIntegration, contextUser) {
|
|
327
|
+
let token;
|
|
328
|
+
let accountID;
|
|
329
|
+
let baseHost;
|
|
330
|
+
const credentialID = companyIntegration.CredentialID;
|
|
331
|
+
if (credentialID) {
|
|
332
|
+
const fromCred = await this.LoadFromCredentialEntity(credentialID, contextUser);
|
|
333
|
+
if (fromCred) {
|
|
334
|
+
token = fromCred.Token ?? token;
|
|
335
|
+
accountID = fromCred.AccountID ?? accountID;
|
|
336
|
+
}
|
|
442
337
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
338
|
+
// Configuration JSON supplies AccountID (and a token fallback) when not on the credential.
|
|
339
|
+
const configJson = companyIntegration.Configuration;
|
|
340
|
+
if (configJson) {
|
|
341
|
+
const fromConfig = this.ParseCredentialJson(configJson);
|
|
342
|
+
if (fromConfig) {
|
|
343
|
+
token = token ?? fromConfig.Token;
|
|
344
|
+
accountID = accountID ?? fromConfig.AccountID;
|
|
345
|
+
baseHost = baseHost ?? fromConfig.BaseHost;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!token) {
|
|
349
|
+
throw new Error('No PropFuel credential or Configuration JSON found — a "Token" is required.');
|
|
446
350
|
}
|
|
447
|
-
|
|
351
|
+
if (!accountID) {
|
|
352
|
+
throw new Error('No PropFuel AccountID found — set "AccountID" on the credential or Configuration JSON (it is per-tenant, never hardcoded).');
|
|
353
|
+
}
|
|
354
|
+
return { Token: token, AccountID: accountID, BaseHost: baseHost };
|
|
448
355
|
}
|
|
449
|
-
|
|
356
|
+
/** Loads Token/AccountID from a Credential entity's Values JSON. */
|
|
357
|
+
async LoadFromCredentialEntity(credentialID, contextUser, provider) {
|
|
450
358
|
const md = provider ?? new Metadata();
|
|
451
359
|
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
452
360
|
const loaded = await credential.Load(credentialID);
|
|
453
361
|
if (!loaded || !credential.Values)
|
|
454
362
|
return null;
|
|
455
|
-
|
|
456
|
-
return this.ExtractConfig(values);
|
|
363
|
+
return this.ParseCredentialJson(credential.Values);
|
|
457
364
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
for (const key of keys) {
|
|
461
|
-
const hit = Object.entries(values).find(([k]) => k.toLowerCase() === key.toLowerCase());
|
|
462
|
-
if (hit)
|
|
463
|
-
return String(hit[1] ?? '');
|
|
464
|
-
}
|
|
465
|
-
return undefined;
|
|
466
|
-
};
|
|
467
|
-
// Accepts vendor-specific PascalCase names AND the field names from the
|
|
468
|
-
// standard 'API Key with Endpoint' credential schema (endpoint, apiKey)
|
|
469
|
-
// so the metadata can reference the standard schema rather than a
|
|
470
|
-
// PropFuel-specific one.
|
|
471
|
-
const baseURL = get('BaseURL', 'BaseUrl', 'base_url', 'endpoint');
|
|
472
|
-
const apiKey = get('ApiKey', 'apiKey', 'api_key');
|
|
473
|
-
if (!baseURL)
|
|
474
|
-
throw new Error('PropFuel configuration missing required field: BaseURL');
|
|
475
|
-
if (!apiKey)
|
|
476
|
-
throw new Error('PropFuel configuration missing required field: ApiKey');
|
|
477
|
-
return { BaseURL: this.StripTrailingSlash(baseURL), ApiKey: apiKey };
|
|
478
|
-
}
|
|
479
|
-
// ── HTTP helpers ────────────────────────────────────────────────
|
|
480
|
-
async ExecuteFetch(url, method, headers, body, timeoutMs) {
|
|
481
|
-
const controller = new AbortController();
|
|
482
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
365
|
+
/** Parses a JSON string to extract Token + AccountID (tolerant of casing/aliases). */
|
|
366
|
+
ParseCredentialJson(json) {
|
|
483
367
|
try {
|
|
484
|
-
const
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
async BuildRESTResponse(response) {
|
|
495
|
-
const headers = {};
|
|
496
|
-
response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
|
|
497
|
-
const contentType = headers['content-type'] ?? '';
|
|
498
|
-
const body = contentType.includes('application/json')
|
|
499
|
-
? await this.ParseJsonSafely(response)
|
|
500
|
-
: await response.text();
|
|
501
|
-
return { Status: response.status, Body: body, Headers: headers };
|
|
502
|
-
}
|
|
503
|
-
async ParseJsonSafely(response) {
|
|
504
|
-
try {
|
|
505
|
-
return await response.json();
|
|
368
|
+
const result = PropFuelCredentialSchema.safeParse(JSON.parse(json));
|
|
369
|
+
if (!result.success)
|
|
370
|
+
return null;
|
|
371
|
+
const p = result.data;
|
|
372
|
+
const token = p.Token ?? p.token ?? p.apiKey ?? p.ApiKey;
|
|
373
|
+
const accountID = p.AccountID ?? p.accountId ?? p.accountID;
|
|
374
|
+
if (!token && accountID == null)
|
|
375
|
+
return null;
|
|
376
|
+
const baseHost = p.apiBaseUrl ?? p.BaseURL;
|
|
377
|
+
return { Token: token ?? '', AccountID: accountID != null ? String(accountID) : '', BaseHost: baseHost };
|
|
506
378
|
}
|
|
507
379
|
catch {
|
|
508
380
|
return null;
|
|
509
381
|
}
|
|
510
382
|
}
|
|
511
|
-
ShouldRetry(status) {
|
|
512
|
-
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
513
|
-
}
|
|
514
|
-
ComputeRetryDelay(response, attempt) {
|
|
515
|
-
const retryAfter = response.headers.get('retry-after');
|
|
516
|
-
if (retryAfter) {
|
|
517
|
-
const seconds = Number.parseInt(retryAfter, 10);
|
|
518
|
-
if (!Number.isNaN(seconds) && seconds > 0)
|
|
519
|
-
return seconds * 1000;
|
|
520
|
-
}
|
|
521
|
-
return Math.min(Math.pow(2, attempt) * 1000, 15_000);
|
|
522
|
-
}
|
|
523
|
-
async Throttle(minIntervalMs) {
|
|
524
|
-
const elapsed = Date.now() - this.lastRequestTime;
|
|
525
|
-
if (elapsed < minIntervalMs)
|
|
526
|
-
await this.Sleep(minIntervalMs - elapsed);
|
|
527
|
-
}
|
|
528
|
-
Sleep(ms) {
|
|
529
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
530
|
-
}
|
|
531
|
-
// ── Misc helpers ────────────────────────────────────────────────
|
|
532
|
-
ExtractIdFromRecord(body) {
|
|
533
|
-
const candidates = ['id', 'ID', 'Id', 'contact_id', 'campaign_id', 'conversation_id', 'capture_id'];
|
|
534
|
-
for (const key of candidates) {
|
|
535
|
-
const value = body[key];
|
|
536
|
-
if (value !== undefined && value !== null)
|
|
537
|
-
return String(value);
|
|
538
|
-
}
|
|
539
|
-
return '';
|
|
540
|
-
}
|
|
541
|
-
StripTrailingSlash(url) {
|
|
542
|
-
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
543
|
-
}
|
|
544
383
|
};
|
|
545
384
|
PropFuelConnector = __decorate([
|
|
546
385
|
RegisterClass(BaseIntegrationConnector, 'PropFuelConnector')
|
|
547
386
|
], PropFuelConnector);
|
|
548
387
|
export { PropFuelConnector };
|
|
549
|
-
|
|
550
|
-
|
|
388
|
+
// ─── Module-level constants + helpers (mechanism, NOT a catalog) ──────
|
|
389
|
+
/** PropFuel host root. Tenant + endpoint are appended at runtime. */
|
|
390
|
+
const PROPFUEL_HOST = 'https://app.propfuel.com';
|
|
391
|
+
/** Stable ordering key name for keyset/no-watermark resume — the filename microtime prefix. */
|
|
392
|
+
const MICROTIME_ORDERING_KEY = 'microtime';
|
|
393
|
+
/** Zod schema for the credential/Configuration JSON shape (tolerant of casing aliases). */
|
|
394
|
+
const PropFuelCredentialSchema = z.object({
|
|
395
|
+
Token: z.string().optional(),
|
|
396
|
+
token: z.string().optional(),
|
|
397
|
+
apiKey: z.string().optional(),
|
|
398
|
+
ApiKey: z.string().optional(),
|
|
399
|
+
AccountID: z.union([z.string(), z.number()]).optional(),
|
|
400
|
+
accountId: z.union([z.string(), z.number()]).optional(),
|
|
401
|
+
accountID: z.union([z.string(), z.number()]).optional(),
|
|
402
|
+
apiBaseUrl: z.string().optional(),
|
|
403
|
+
BaseURL: z.string().optional(),
|
|
404
|
+
}).passthrough();
|
|
405
|
+
/** Narrows an unknown value to a plain record. */
|
|
406
|
+
function isRecord(v) {
|
|
407
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Parses a PropFuel data-export filename of the form `[microtime]-[datatype].json`.
|
|
411
|
+
* The microtime is the leading numeric segment (PHP microtime, e.g. `1717804800.123456`); the data
|
|
412
|
+
* type is everything between the first `-` and the `.json` suffix. Returns null when the name does
|
|
413
|
+
* not match the convention.
|
|
414
|
+
*/
|
|
415
|
+
export function parseFileName(fileName) {
|
|
416
|
+
// Strip any path prefix and the .json extension.
|
|
417
|
+
const base = fileName.split('/').pop() ?? fileName;
|
|
418
|
+
const noExt = base.replace(/\.json$/i, '');
|
|
419
|
+
const dashIndex = noExt.indexOf('-');
|
|
420
|
+
if (dashIndex <= 0)
|
|
421
|
+
return null;
|
|
422
|
+
const microtime = noExt.slice(0, dashIndex);
|
|
423
|
+
const dataType = noExt.slice(dashIndex + 1);
|
|
424
|
+
if (!/^[0-9]+(\.[0-9]+)?$/.test(microtime) || dataType.length === 0)
|
|
425
|
+
return null;
|
|
426
|
+
return { microtime, dataType };
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Compares two microtime strings chronologically. Microtimes are numeric (possibly fractional);
|
|
430
|
+
* numeric comparison avoids lexical pitfalls (e.g. different integer widths). Returns >0 when a>b.
|
|
431
|
+
*/
|
|
432
|
+
export function compareMicrotime(a, b) {
|
|
433
|
+
const na = Number(a);
|
|
434
|
+
const nb = Number(b);
|
|
435
|
+
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
|
436
|
+
return na === nb ? 0 : (na > nb ? 1 : -1);
|
|
437
|
+
}
|
|
438
|
+
return a === b ? 0 : (a > b ? 1 : -1);
|
|
439
|
+
}
|
|
440
|
+
/** Small deterministic hash for record identity fallback (FNV-1a, hex). */
|
|
441
|
+
function stableHash(record) {
|
|
442
|
+
const json = JSON.stringify(record, Object.keys(record).sort());
|
|
443
|
+
let h = 0x811c9dc5;
|
|
444
|
+
for (let i = 0; i < json.length; i++) {
|
|
445
|
+
h ^= json.charCodeAt(i);
|
|
446
|
+
h = Math.imul(h, 0x01000193);
|
|
447
|
+
}
|
|
448
|
+
return (h >>> 0).toString(16);
|
|
449
|
+
}
|
|
551
450
|
//# sourceMappingURL=PropFuelConnector.js.map
|