@memberjunction/connector-eventbrite 1.1.0 → 2.0.1
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/EventbriteConnector.d.ts +158 -151
- package/dist/EventbriteConnector.js +508 -395
- package/dist/EventbriteConnector.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,139 +6,74 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
6
6
|
};
|
|
7
7
|
import { RegisterClass } from '@memberjunction/global';
|
|
8
8
|
import { Metadata } from '@memberjunction/core';
|
|
9
|
-
import {
|
|
9
|
+
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
10
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector,
|
|
11
|
+
// NOTE: no auth-helper crypto/token-exchange imported. Eventbrite auth is a PRE-MINTED, long-lived
|
|
12
|
+
// Bearer token (the account "Private Token" from the API Keys page, or the access token minted once by
|
|
13
|
+
// the OAuth2 authorization-code exchange). The frozen contract documents NO refresh_token grant and NO
|
|
14
|
+
// TTL (Configuration.TokenRefreshStrategy = null), so there is no in-connector token round-trip to run —
|
|
15
|
+
// OAuth2TokenManager would fabricate an undocumented exchange. Every documented request sends the token
|
|
16
|
+
// verbatim as `Authorization: Bearer <token>`, exactly like a static Bearer key. No signing, no crypto.
|
|
17
|
+
} from '@memberjunction/integration-engine';
|
|
10
18
|
import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
|
|
11
19
|
// ─── Constants ────────────────────────────────────────────────────────
|
|
12
|
-
const PROD_API_HOST = 'https://www.eventbriteapi.com/v3';
|
|
13
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
14
|
-
const DEFAULT_MAX_RETRIES = 4;
|
|
15
|
-
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 100;
|
|
16
20
|
/**
|
|
17
|
-
* Eventbrite
|
|
18
|
-
*
|
|
19
|
-
* engine's AIMD bucket throttles + backs off on the 429 HIT_RATE_LIMIT error.
|
|
21
|
+
* Eventbrite API host with the v3 version segment embedded in the path (Configuration.APIVersioningNote —
|
|
22
|
+
* HOST directive `https://www.eventbriteapi.com/v3/`). Every IO's APIPath is relative to this v3 host.
|
|
20
23
|
*/
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
const EVENTBRITE_API_BASE = 'https://www.eventbriteapi.com/v3';
|
|
25
|
+
/** Query param carrying the continuation cursor (Configuration.PaginationDefaults.continuationParam). */
|
|
26
|
+
const CONTINUATION_PARAM = 'continuation';
|
|
27
|
+
/** Query param carrying the incremental datetime cursor for Attendee + Order. */
|
|
28
|
+
const CHANGED_SINCE_PARAM = 'changed_since';
|
|
29
|
+
// ─── EventbriteConnector ───────────────────────────────────────────────
|
|
24
30
|
/**
|
|
25
|
-
*
|
|
31
|
+
* Eventbrite events/ticketing connector — extends BaseRESTIntegrationConnector (REST/JSON over HTTP).
|
|
26
32
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* - {@link BuildHeaders}: `Authorization: Bearer <token>` + `Accept: application/json`.
|
|
32
|
-
* - {@link GetBaseURL}: the shared `https://www.eventbriteapi.com/v3` host (or a
|
|
33
|
-
* non-secret `ApiBaseUrl` override for mock testing).
|
|
34
|
-
* - {@link NormalizeResponse}: unwraps the named-key list envelope (the IO's
|
|
35
|
-
* `ResponseDataKey`, e.g. `events`/`attendees`/`orders`) which sits alongside a
|
|
36
|
-
* `pagination` object; a bare object is a one-record detail; null → [].
|
|
37
|
-
* - {@link ExtractPaginationInfo} / {@link BuildPaginatedURL}: Eventbrite continuation-cursor
|
|
38
|
-
* pagination (`?continuation=<token>` + `pagination.has_more_items`/`.continuation`),
|
|
39
|
-
* plus the `changed_since=<watermark>` param for incremental objects (Order/Attendee) —
|
|
40
|
-
* fully metadata-driven (read `obj.IncrementalWatermarkField` + `obj.SupportsIncrementalSync`),
|
|
41
|
-
* NEVER keyed off a hardcoded object name.
|
|
42
|
-
* - {@link FetchChanges}: sets the watermark context, delegates to the base, then advances
|
|
43
|
-
* the watermark from the records' `changed` field on the final batch only (HasMore=false)
|
|
44
|
-
* so a partial-failure mid-pagination leaves the watermark unchanged.
|
|
45
|
-
*
|
|
46
|
-
* Create/Update/Delete use the generic per-operation column path
|
|
47
|
-
* (CreateAPIPath/Method/BodyShape/BodyKey/IDLocation, Update*, Delete*) driven entirely
|
|
48
|
-
* by the IO metadata — no per-verb override. Eventbrite uses POST for update (its
|
|
49
|
-
* convention) and wrapped bodies for most resources (CreateBodyKey =
|
|
50
|
-
* event|ticket_class|venue|discount|question|webhook|...). Parent template vars
|
|
51
|
-
* ({organization_id}/{event_id}) are resolved by the engine's parent-iteration from each
|
|
52
|
-
* IO's `Configuration.parentObjectName`.
|
|
33
|
+
* Discovery, template-var parent traversal, and the paginated GET loop are inherited. This class supplies
|
|
34
|
+
* only the Eventbrite-specific protocol surface: Bearer auth, the continuation-token cursor, the
|
|
35
|
+
* `changed_since` incremental pull, connection testing, the write path (vendor-named path vars), and the
|
|
36
|
+
* §7/§10 sync-efficiency hooks the frozen contract evidences.
|
|
53
37
|
*/
|
|
38
|
+
// Primary key follows the catalog convention (className == npm package name;
|
|
39
|
+
// see scripts/build-connectors-catalog.mjs) — instance discovery reports the
|
|
40
|
+
// package name, so the legacy bare key never matched in the catalog. The
|
|
41
|
+
// legacy alias stays registered so pre-migration tenant Integration rows
|
|
42
|
+
// keep resolving.
|
|
54
43
|
let EventbriteConnector = class EventbriteConnector extends BaseRESTIntegrationConnector {
|
|
55
44
|
constructor() {
|
|
56
45
|
super(...arguments);
|
|
57
|
-
/** Cached auth
|
|
58
|
-
this.
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
46
|
+
/** Cached auth for the lifetime of a single sync run (Eventbrite tokens are long-lived, no refresh). */
|
|
47
|
+
this.cachedAuth = null;
|
|
48
|
+
/**
|
|
49
|
+
* The active incremental watermark for the object currently being fetched, stashed by the FetchChanges
|
|
50
|
+
* override so the (ctx-less) BuildPaginatedURL can append `changed_since`. Set at the top of an
|
|
51
|
+
* incremental FetchChanges, cleared in its finally. Safe because the engine drives one FetchChanges per
|
|
52
|
+
* object at a time (single-threaded async; no concurrent BuildPaginatedURL for a different watermark).
|
|
53
|
+
*/
|
|
54
|
+
this.activeChangedSince = null;
|
|
55
|
+
}
|
|
56
|
+
// ── Identity (T1 three-way invariant) ────────────────────────────
|
|
57
|
+
/** Verbatim `MJ: Integrations.Name`. Load-bearing: the T1 three-way name check compares this === metadata Name. */
|
|
58
|
+
get IntegrationName() {
|
|
59
|
+
return 'eventbrite';
|
|
60
|
+
}
|
|
61
|
+
// ── Capability getters (kept in lockstep with the per-op metadata columns) ──
|
|
62
|
+
// Write surface is a MIXED subset (Configuration.WriteCapability): create on 15/33 objects, update on a
|
|
63
|
+
// subset, delete on a smaller subset. The connector-level getters report the UNION (the connector CAN do
|
|
64
|
+
// each verb); per-object null-column honesty is enforced by the overrides below (an object with no
|
|
65
|
+
// Create/Update/Delete path fails loudly rather than sending a broken URL).
|
|
70
66
|
get SupportsCreate() { return true; }
|
|
71
67
|
get SupportsUpdate() { return true; }
|
|
72
68
|
get SupportsDelete() { return true; }
|
|
73
|
-
// ── Sync-efficiency hooks ───────────────────────────────────────────
|
|
74
|
-
/**
|
|
75
|
-
* Eventbrite documents a default 2,000 calls/hour per token (HTTP 429 HIT_RATE_LIMIT
|
|
76
|
-
* over it). Run under that ceiling; the engine's AIMD bucket throttles + backs off.
|
|
77
|
-
*/
|
|
78
|
-
get RateLimitPolicy() {
|
|
79
|
-
return { TokensPerSec: RATE_LIMIT_TOKENS_PER_SEC, Burst: RATE_LIMIT_BURST, ThrottleBackoffFactor: 0.5 };
|
|
80
|
-
}
|
|
81
69
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* op-timeout — the batch is then abandoned and the object syncs 0 records for any org with that
|
|
87
|
-
* many events. A batch of 4 costs ~15 s (well under 30 s) and the engine resumes the remaining
|
|
88
|
-
* parents via HasMore/keyset, so a high-event-count org still syncs completely.
|
|
70
|
+
* Discovery is NON-authoritative: DiscoverObjects / IntrospectSchema are cache-driven (they re-read
|
|
71
|
+
* persisted ACTIVE Declared metadata, NOT a live full-gamut enumeration). Eventbrite publishes no
|
|
72
|
+
* schema/describe/introspection endpoint enumerating everything a credential can access, so absence in a
|
|
73
|
+
* refresh proves nothing → never deactivate. Matches Configuration.DiscoveryIsAuthoritative=false.
|
|
89
74
|
*/
|
|
90
|
-
|
|
91
|
-
return
|
|
92
|
-
}
|
|
93
|
-
/** Parse Eventbrite's Retry-After header (delta-seconds or HTTP-date) into milliseconds. */
|
|
94
|
-
ExtractRetryAfterMs(error) {
|
|
95
|
-
const headers = error?.Headers;
|
|
96
|
-
if (!headers)
|
|
97
|
-
return undefined;
|
|
98
|
-
const retryAfter = headers['retry-after'] ?? headers['Retry-After'];
|
|
99
|
-
if (typeof retryAfter !== 'string' || retryAfter.length === 0)
|
|
100
|
-
return undefined;
|
|
101
|
-
const asSeconds = Number(retryAfter);
|
|
102
|
-
if (!isNaN(asSeconds) && asSeconds >= 0)
|
|
103
|
-
return Math.round(asSeconds * 1000);
|
|
104
|
-
const asDate = Date.parse(retryAfter);
|
|
105
|
-
if (!isNaN(asDate)) {
|
|
106
|
-
const delta = asDate - Date.now();
|
|
107
|
-
if (delta > 0)
|
|
108
|
-
return delta;
|
|
109
|
-
}
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
// ─── TestConnection ──────────────────────────────────────────────
|
|
113
|
-
/**
|
|
114
|
-
* Verifies connectivity via `GET /users/me/`. A 2xx confirms the Bearer token is valid;
|
|
115
|
-
* a 401/403 means the token was rejected (NOT_AUTH / NOT_PERMITTED).
|
|
116
|
-
*/
|
|
117
|
-
async TestConnection(companyIntegration, contextUser) {
|
|
118
|
-
try {
|
|
119
|
-
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
120
|
-
const headers = this.BuildHeaders(auth);
|
|
121
|
-
const probeUrl = `${auth.BaseUrl}/users/me/`;
|
|
122
|
-
const resp = await this.MakeHTTPRequest(auth, probeUrl, 'GET', headers);
|
|
123
|
-
if (resp.Status === 401 || resp.Status === 403) {
|
|
124
|
-
return { Success: false, Message: `Eventbrite TestConnection failed: HTTP ${resp.Status} (token rejected)` };
|
|
125
|
-
}
|
|
126
|
-
if (resp.Status >= 500) {
|
|
127
|
-
return { Success: false, Message: `Eventbrite TestConnection failed: HTTP ${resp.Status} (server error)` };
|
|
128
|
-
}
|
|
129
|
-
if (resp.Status < 200 || resp.Status >= 300) {
|
|
130
|
-
return { Success: false, Message: `Eventbrite returned HTTP ${resp.Status} from ${probeUrl}` };
|
|
131
|
-
}
|
|
132
|
-
return {
|
|
133
|
-
Success: true,
|
|
134
|
-
Message: `Connected to Eventbrite Platform API v3 at ${auth.BaseUrl}`,
|
|
135
|
-
ServerVersion: 'Eventbrite API v3',
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
catch (err) {
|
|
139
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
140
|
-
return { Success: false, Message: `Connection failed: ${message}` };
|
|
141
|
-
}
|
|
75
|
+
get DiscoveryIsAuthoritative() {
|
|
76
|
+
return false;
|
|
142
77
|
}
|
|
143
78
|
/**
|
|
144
79
|
* IntrospectSchema — pure WIRING of MJ's existing sampler into the declared catalog (the connector
|
|
@@ -171,352 +106,530 @@ let EventbriteConnector = class EventbriteConnector extends BaseRESTIntegrationC
|
|
|
171
106
|
});
|
|
172
107
|
return schema;
|
|
173
108
|
}
|
|
174
|
-
//
|
|
109
|
+
// ── Sync-efficiency hooks (§7/§10 — populated from frozen-contract Configuration facts) ──
|
|
175
110
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* `changed` field on the FINAL batch only (HasMore=false) so a partial-failure
|
|
180
|
-
* mid-pagination leaves the watermark unchanged.
|
|
111
|
+
* From Configuration.RateLimitPolicy: 2,000 calls/hour per token (blueprint ## Errors, 429
|
|
112
|
+
* HIT_RATE_LIMIT). 2000/3600 ≈ 0.556 tokens/sec sustained. Burst kept small (the hourly ceiling is the
|
|
113
|
+
* real constraint; there is no documented per-second burst allowance).
|
|
181
114
|
*/
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const result = await super.FetchChanges(ctx);
|
|
185
|
-
const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
186
|
-
if (obj.SupportsIncrementalSync && obj.IncrementalWatermarkField && !result.HasMore) {
|
|
187
|
-
const advanced = this.ExtractLatestWatermark(result.Records, obj.IncrementalWatermarkField);
|
|
188
|
-
const newWatermark = advanced ?? ctx.WatermarkValue ?? undefined;
|
|
189
|
-
if (newWatermark != null)
|
|
190
|
-
return { ...result, NewWatermarkValue: newWatermark };
|
|
191
|
-
}
|
|
192
|
-
return result;
|
|
115
|
+
get RateLimitPolicy() {
|
|
116
|
+
return { TokensPerSec: 0.5556, Burst: 5 };
|
|
193
117
|
}
|
|
194
118
|
/**
|
|
195
|
-
*
|
|
196
|
-
* (
|
|
197
|
-
* the
|
|
198
|
-
*
|
|
119
|
+
* The frozen contract records NO Retry-After header shape for Eventbrite's 429 (HIT_RATE_LIMIT)
|
|
120
|
+
* (Configuration.RateLimitPolicy.retryAfterHeaderDocumented=false), so there is nothing to parse
|
|
121
|
+
* reliably. Left as the base default (undefined) rather than guessing a header name — the engine's AIMD
|
|
122
|
+
* bucket backs off on the 429 regardless. Documented as a soft gap for live-probe confirmation.
|
|
199
123
|
*/
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (typeof raw !== 'string' || raw.length === 0)
|
|
205
|
-
continue;
|
|
206
|
-
const d = new Date(raw);
|
|
207
|
-
if (!isNaN(d.getTime()) && (latest === null || d > latest))
|
|
208
|
-
latest = d;
|
|
209
|
-
}
|
|
210
|
-
return latest ? latest.toISOString() : null;
|
|
124
|
+
// ExtractRetryAfterMs: inherited default (undefined). See note above.
|
|
125
|
+
/** Conservative in-flight cap. The 2,000/hour ceiling is the real limiter; a low cap avoids bursts. */
|
|
126
|
+
get MaxConcurrencyHint() {
|
|
127
|
+
return 2;
|
|
211
128
|
}
|
|
212
|
-
|
|
129
|
+
/**
|
|
130
|
+
* No-watermark objects resume by their StableOrderingKey — read from the IO metadata when the extractor
|
|
131
|
+
* emitted one, else the object's PK (Eventbrite's universal `id`). Returns null when the object has no
|
|
132
|
+
* stable key or the cache is unavailable (unit-test context).
|
|
133
|
+
*/
|
|
134
|
+
StableOrderingKey(objectName) {
|
|
135
|
+
const obj = this.TryGetActiveObject(objectName);
|
|
136
|
+
if (!obj)
|
|
137
|
+
return null;
|
|
138
|
+
const declared = obj.StableOrderingKey;
|
|
139
|
+
if (declared && declared.trim().length > 0)
|
|
140
|
+
return declared.trim();
|
|
141
|
+
const pk = this.GetCachedFields(obj.ID).find(f => f.IsPrimaryKey);
|
|
142
|
+
return pk?.Name ?? null;
|
|
143
|
+
}
|
|
144
|
+
// ── Abstract REST hooks ──────────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Resolves the pre-minted Bearer token from the linked Credential entity (preferred) or the
|
|
147
|
+
* CompanyIntegration Configuration JSON (fallback). Cached for the run.
|
|
148
|
+
*/
|
|
213
149
|
async Authenticate(companyIntegration, contextUser) {
|
|
214
|
-
if (this.
|
|
215
|
-
return this.
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
BaseUrl: this.resolveBaseUrl(config),
|
|
220
|
-
Config: config,
|
|
221
|
-
};
|
|
222
|
-
this.authState = state;
|
|
223
|
-
return state;
|
|
150
|
+
if (this.cachedAuth)
|
|
151
|
+
return this.cachedAuth;
|
|
152
|
+
const creds = await this.LoadCredentials(companyIntegration, contextUser);
|
|
153
|
+
this.cachedAuth = { Token: creds.AccessToken };
|
|
154
|
+
return this.cachedAuth;
|
|
224
155
|
}
|
|
225
|
-
/**
|
|
156
|
+
/** Eventbrite OAuth2 auth: a pre-minted Bearer token. No signing, no crypto. */
|
|
226
157
|
BuildHeaders(auth) {
|
|
227
|
-
const
|
|
158
|
+
const token = auth.Token;
|
|
228
159
|
return {
|
|
229
|
-
'Authorization': `Bearer ${
|
|
230
|
-
'Accept': 'application/json',
|
|
160
|
+
'Authorization': `Bearer ${token}`,
|
|
231
161
|
'Content-Type': 'application/json',
|
|
162
|
+
'Accept': 'application/json',
|
|
232
163
|
};
|
|
233
164
|
}
|
|
165
|
+
/** HTTP transport (fetch). Owns the wire boundary; test subclasses override this to capture requests. */
|
|
166
|
+
async MakeHTTPRequest(_auth, url, method, headers, body) {
|
|
167
|
+
const response = await fetch(url, {
|
|
168
|
+
method,
|
|
169
|
+
headers,
|
|
170
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
171
|
+
});
|
|
172
|
+
const respHeaders = {};
|
|
173
|
+
response.headers.forEach((v, k) => { respHeaders[k.toLowerCase()] = v; });
|
|
174
|
+
const text = await response.text();
|
|
175
|
+
let parsed = null;
|
|
176
|
+
if (text.length > 0) {
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(text);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
parsed = text;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { Status: response.status, Body: parsed, Headers: respHeaders };
|
|
185
|
+
}
|
|
234
186
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
187
|
+
* Strips the Eventbrite list envelope. Each list endpoint nests records under a plural snake_case
|
|
188
|
+
* resource key (`events`, `attendees`, `orders`, …), stored per-IO as ResponseDataKey. When
|
|
189
|
+
* ResponseDataKey is unset (the extractor left it null), the key is DERIVED from the last non-templated
|
|
190
|
+
* path segment of the APIPath — but NormalizeResponse doesn't have the IO here, so the fallback scans the
|
|
191
|
+
* envelope for the sole array-valued key alongside `pagination`. A bare-array or single-object body (the
|
|
192
|
+
* get-one / non-paginated shape) is handled directly.
|
|
241
193
|
*/
|
|
242
194
|
NormalizeResponse(rawBody, responseDataKey) {
|
|
243
195
|
if (rawBody == null)
|
|
244
196
|
return [];
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (
|
|
248
|
-
if (responseDataKey in asObj)
|
|
249
|
-
return this.coerceToArray(asObj[responseDataKey]);
|
|
250
|
-
// Key declared but absent (e.g. an error or empty envelope) — nothing to emit.
|
|
197
|
+
if (Array.isArray(rawBody))
|
|
198
|
+
return rawBody;
|
|
199
|
+
if (typeof rawBody !== 'object')
|
|
251
200
|
return [];
|
|
201
|
+
const body = rawBody;
|
|
202
|
+
// Preferred: the metadata-declared data key.
|
|
203
|
+
if (responseDataKey) {
|
|
204
|
+
const arr = body[responseDataKey];
|
|
205
|
+
if (Array.isArray(arr))
|
|
206
|
+
return arr;
|
|
252
207
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
208
|
+
// Fallback: an Eventbrite list body has exactly one array-valued key besides `pagination`.
|
|
209
|
+
if (body.pagination !== undefined) {
|
|
210
|
+
for (const [key, val] of Object.entries(body)) {
|
|
211
|
+
if (key === 'pagination')
|
|
212
|
+
continue;
|
|
213
|
+
if (Array.isArray(val))
|
|
214
|
+
return val;
|
|
215
|
+
}
|
|
216
|
+
return [];
|
|
256
217
|
}
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
return rawBody.filter(this.isRecord);
|
|
260
|
-
return [];
|
|
261
|
-
}
|
|
262
|
-
coerceToArray(node) {
|
|
263
|
-
if (Array.isArray(node))
|
|
264
|
-
return node.filter(this.isRecord);
|
|
265
|
-
if (node && typeof node === 'object')
|
|
266
|
-
return [node];
|
|
267
|
-
return [];
|
|
268
|
-
}
|
|
269
|
-
isRecord(node) {
|
|
270
|
-
return node != null && typeof node === 'object' && !Array.isArray(node);
|
|
271
|
-
}
|
|
272
|
-
/**
|
|
273
|
-
* Continuation-cursor pagination: Eventbrite returns a `pagination` envelope carrying
|
|
274
|
-
* `has_more_items` (boolean) and `continuation` (opaque cursor token). There are more
|
|
275
|
-
* records while `has_more_items` is true AND a continuation token is present. The
|
|
276
|
-
* total record count (`object_count`) is surfaced when available.
|
|
277
|
-
*
|
|
278
|
-
* The `Cursor` pagination type drives this; non-cursor objects (`None`) report no more.
|
|
279
|
-
*/
|
|
280
|
-
ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, _pageSize) {
|
|
281
|
-
if (paginationType !== 'Cursor') {
|
|
282
|
-
return { HasMore: false, NextPage: currentPage, NextOffset: currentOffset };
|
|
283
|
-
}
|
|
284
|
-
const asObj = this.asObject(rawBody);
|
|
285
|
-
const pagination = asObj ? this.asObject(asObj['pagination']) : undefined;
|
|
286
|
-
const hasMore = pagination?.has_more_items === true
|
|
287
|
-
&& typeof pagination?.continuation === 'string'
|
|
288
|
-
&& pagination.continuation.length > 0;
|
|
289
|
-
const totalRecords = typeof pagination?.object_count === 'number' ? pagination.object_count : undefined;
|
|
290
|
-
return {
|
|
291
|
-
HasMore: hasMore,
|
|
292
|
-
NextPage: currentPage + 1,
|
|
293
|
-
NextOffset: currentOffset,
|
|
294
|
-
NextCursor: hasMore ? pagination?.continuation : undefined,
|
|
295
|
-
TotalRecords: totalRecords,
|
|
296
|
-
};
|
|
218
|
+
// get-one / non-list shape: the body IS the record.
|
|
219
|
+
return [body];
|
|
297
220
|
}
|
|
298
221
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* NEVER keyed off a hardcoded object name.
|
|
305
|
-
*
|
|
306
|
-
* Parent template vars ({organization_id}/{event_id}) are NOT substituted here — those are
|
|
307
|
-
* resolved by the engine's parent-iteration from the IO's `Configuration.parentObjectName`.
|
|
222
|
+
* Eventbrite pagination is the CONTINUATION-TOKEN scheme (NOT page-number/offset). Read
|
|
223
|
+
* `pagination.has_more_items` and `pagination.continuation` from the envelope. When has_more_items is
|
|
224
|
+
* true AND a continuation token is present, the next page is requested with that token; otherwise the set
|
|
225
|
+
* is exhausted. Source: Configuration.PaginationDefaults.advanceProtocol.
|
|
226
|
+
* currentPage/offset/pageSize are unused for continuation pagination.
|
|
308
227
|
*/
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
// Continuation cursor for the Cursor pagination type (after the first page).
|
|
318
|
-
if (obj.PaginationType === 'Cursor' && cursor) {
|
|
319
|
-
params.set('continuation', cursor);
|
|
228
|
+
ExtractPaginationInfo(rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
|
|
229
|
+
if (rawBody && typeof rawBody === 'object') {
|
|
230
|
+
const env = rawBody;
|
|
231
|
+
const pag = env.pagination;
|
|
232
|
+
if (pag && pag.has_more_items === true && typeof pag.continuation === 'string' && pag.continuation.length > 0) {
|
|
233
|
+
return { HasMore: true, NextCursor: pag.continuation, TotalRecords: pag.object_count };
|
|
234
|
+
}
|
|
235
|
+
return { HasMore: false, TotalRecords: pag?.object_count };
|
|
320
236
|
}
|
|
321
|
-
|
|
322
|
-
return qs.length > 0 ? `${basePath}${separator}${qs}` : basePath;
|
|
237
|
+
return { HasMore: false };
|
|
323
238
|
}
|
|
324
239
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
240
|
+
* Eventbrite v3 host. Defaults to the fixed `https://www.eventbriteapi.com/v3` host (the version
|
|
241
|
+
* segment is part of the base URL; metadata APIPaths are relative). Honors a
|
|
242
|
+
* `CompanyIntegration.Configuration.BaseURL` override when present — used for region redirects and
|
|
243
|
+
* for pointing the connector at a mock ORIGIN server in credential-free e2e testing without touching
|
|
244
|
+
* the vendor's real endpoint. Falls back to the fixed host on absence or malformed Configuration.
|
|
329
245
|
*/
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
return 'changed_since';
|
|
334
|
-
}
|
|
335
|
-
GetBaseURL(_companyIntegration, auth) {
|
|
336
|
-
return auth.BaseUrl;
|
|
337
|
-
}
|
|
338
|
-
// ─── HTTP transport with retry + throttling ───────────────────────
|
|
339
|
-
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
340
|
-
const ebAuth = auth;
|
|
341
|
-
const cfg = ebAuth.Config;
|
|
342
|
-
const maxRetries = cfg.MaxRetries ?? DEFAULT_MAX_RETRIES;
|
|
343
|
-
const timeoutMs = cfg.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
344
|
-
const minInterval = cfg.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
|
|
345
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
346
|
-
await this.throttle(minInterval);
|
|
246
|
+
GetBaseURL(companyIntegration) {
|
|
247
|
+
const cfgRaw = companyIntegration?.Configuration;
|
|
248
|
+
if (cfgRaw) {
|
|
347
249
|
try {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
continue;
|
|
250
|
+
const cfg = (typeof cfgRaw === 'string' ? JSON.parse(cfgRaw) : cfgRaw);
|
|
251
|
+
const override = cfg?.BaseURL ?? cfg?.baseUrl ?? cfg?.apiBaseUrl;
|
|
252
|
+
if (typeof override === 'string' && override.trim().length > 0) {
|
|
253
|
+
return override.replace(/\/+$/, '');
|
|
353
254
|
}
|
|
354
|
-
return resp;
|
|
355
255
|
}
|
|
356
|
-
catch
|
|
357
|
-
|
|
358
|
-
throw err;
|
|
359
|
-
if (!this.isRetryableError(err))
|
|
360
|
-
throw err;
|
|
361
|
-
await this.sleep(this.backoffMs(attempt));
|
|
256
|
+
catch {
|
|
257
|
+
/* malformed Configuration JSON → fall back to the fixed host */
|
|
362
258
|
}
|
|
363
259
|
}
|
|
364
|
-
|
|
260
|
+
return EVENTBRITE_API_BASE;
|
|
365
261
|
}
|
|
366
|
-
/**
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Eventbrite pages via the `continuation` query param (NOT the base default `cursor=`). The first page
|
|
264
|
+
* sends no continuation token; subsequent pages send `continuation=<token>`. When an incremental
|
|
265
|
+
* watermark is active (Attendee/Order this run), `changed_since=<watermark>` is appended so the API
|
|
266
|
+
* returns only records changed after the watermark. Eventbrite has no client-controlled page-size param
|
|
267
|
+
* on these list endpoints (page_size is server-fixed and reported in the envelope), so no limit is sent.
|
|
268
|
+
*/
|
|
269
|
+
BuildPaginatedURL(basePath, _obj, _page, _offset, cursor, _effectivePageSize) {
|
|
270
|
+
const parts = [];
|
|
271
|
+
if (cursor)
|
|
272
|
+
parts.push(`${CONTINUATION_PARAM}=${encodeURIComponent(cursor)}`);
|
|
273
|
+
if (this.activeChangedSince)
|
|
274
|
+
parts.push(`${CHANGED_SINCE_PARAM}=${encodeURIComponent(this.activeChangedSince)}`);
|
|
275
|
+
if (parts.length === 0)
|
|
276
|
+
return basePath;
|
|
277
|
+
const separator = basePath.includes('?') ? '&' : '?';
|
|
278
|
+
return `${basePath}${separator}${parts.join('&')}`;
|
|
279
|
+
}
|
|
280
|
+
// ── FetchChanges override (incremental changed_since + new-watermark emission) ──
|
|
281
|
+
/**
|
|
282
|
+
* OVERRIDDEN to (1) inject the `changed_since` param for the two incremental objects (Attendee, Order —
|
|
283
|
+
* SupportsIncrementalSync + a watermark this run) and (2) EMIT the new watermark. The base flat/template
|
|
284
|
+
* fetch path threads no watermark into the URL and returns no NewWatermarkValue, so the connector owns
|
|
285
|
+
* both. All fetching (continuation pagination, template-var parent traversal) is delegated to the base;
|
|
286
|
+
* this override only sets activeChangedSince around the super call and computes the watermark on a fully
|
|
287
|
+
* drained batch.
|
|
288
|
+
*
|
|
289
|
+
* Partial-failure safety: NewWatermarkValue is emitted ONLY when the whole object is drained
|
|
290
|
+
* (HasMore=false). A mid-stream batch (HasMore=true — more parents to iterate) advances no watermark, so a
|
|
291
|
+
* failure between batches resumes from the unchanged prior watermark. When the final batch's max `changed`
|
|
292
|
+
* is below an earlier batch's, the watermark under-advances (worst case re-fetches already-seen records
|
|
293
|
+
* next run — idempotent, safe) rather than skipping records (data loss).
|
|
294
|
+
*/
|
|
295
|
+
async FetchChanges(ctx) {
|
|
296
|
+
const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
297
|
+
const incremental = obj.SupportsIncrementalSync && ctx.WatermarkValue != null && ctx.WatermarkValue.length > 0;
|
|
298
|
+
if (!incremental) {
|
|
299
|
+
return super.FetchChanges(ctx);
|
|
300
|
+
}
|
|
301
|
+
this.activeChangedSince = this.FormatChangedSince(ctx.WatermarkValue);
|
|
370
302
|
try {
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const respHeaders = {};
|
|
378
|
-
resp.headers.forEach((value, key) => { respHeaders[key.toLowerCase()] = value; });
|
|
379
|
-
const text = await resp.text();
|
|
380
|
-
const parsed = text.length > 0 ? this.safeParseJSON(text) : null;
|
|
381
|
-
return { Status: resp.status, Body: parsed, Headers: respHeaders };
|
|
303
|
+
const result = await super.FetchChanges(ctx);
|
|
304
|
+
if (!result.HasMore) {
|
|
305
|
+
const wmField = obj.IncrementalWatermarkField ?? 'changed';
|
|
306
|
+
result.NewWatermarkValue = this.MaxWatermark(result, wmField, ctx.WatermarkValue);
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
382
309
|
}
|
|
383
310
|
finally {
|
|
384
|
-
|
|
311
|
+
this.activeChangedSince = null;
|
|
385
312
|
}
|
|
386
313
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
314
|
+
// ── CRUD ──────────────────────────────────────────────────────────
|
|
315
|
+
//
|
|
316
|
+
// OVERRIDDEN (all three verbs) because Eventbrite's write paths carry VENDOR-NAMED template vars the
|
|
317
|
+
// base's single-{id} SubstituteIDInPath cannot build: create paths carry a parent var
|
|
318
|
+
// (`{organization_id}`/`{event_id}`); update/delete paths carry the record's own id under a vendor name
|
|
319
|
+
// (`{event_id}`, `{venue_id}`, `{discount_id}`, …) and several also carry a parent var
|
|
320
|
+
// (`/events/{event_id}/ticket_classes/{ticket_class_id}/`). Body shaping, ID extraction, and error
|
|
321
|
+
// handling all reuse the base helpers; create STILL routes through BuildCreatedResult (loud-on-empty-id).
|
|
322
|
+
/**
|
|
323
|
+
* Create: substitutes the create path's parent vars from Attributes/Relationships (create paths carry
|
|
324
|
+
* only parent vars — the record has no id yet), POSTs the body shaped per CreateBodyShape/CreateBodyKey,
|
|
325
|
+
* and routes the result through BuildCreatedResult so a 2xx with no usable id FAILS LOUDLY.
|
|
326
|
+
*/
|
|
327
|
+
async CreateRecord(ctx) {
|
|
328
|
+
const ci = ctx.CompanyIntegration;
|
|
329
|
+
const contextUser = ctx.ContextUser;
|
|
330
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
331
|
+
if (!obj.CreateAPIPath || !obj.CreateMethod) {
|
|
332
|
+
return { Success: false, StatusCode: 0, ErrorMessage: `[eventbrite] CreateRecord not supported for "${ctx.ObjectName}": CreateAPIPath / CreateMethod not configured.` };
|
|
390
333
|
}
|
|
391
|
-
|
|
392
|
-
|
|
334
|
+
const resolved = this.SubstituteAllPathVars(obj.CreateAPIPath, undefined, ctx.Attributes, ctx.Relationships);
|
|
335
|
+
if (resolved.unresolved.length > 0) {
|
|
336
|
+
return this.UnresolvedVarError(ctx.ObjectName, 'create', obj.CreateAPIPath, resolved.unresolved);
|
|
393
337
|
}
|
|
338
|
+
const auth = await this.Authenticate(ci, contextUser);
|
|
339
|
+
const headers = this.BuildHeaders(auth);
|
|
340
|
+
const url = `${this.GetBaseURL(ci)}${resolved.path}`;
|
|
341
|
+
const body = this.BuildOperationBody(ctx.Attributes, obj.CreateBodyShape, obj.CreateBodyKey);
|
|
342
|
+
const response = await this.MakeHTTPRequest(auth, url, obj.CreateMethod, headers, body);
|
|
343
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
344
|
+
const externalID = this.ExtractIDFromResponse(response, obj.CreateIDLocation);
|
|
345
|
+
return this.BuildCreatedResult(externalID, response.Status, ctx.ObjectName);
|
|
346
|
+
}
|
|
347
|
+
return { Success: false, StatusCode: response.Status,
|
|
348
|
+
ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on create` };
|
|
394
349
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Update: substitutes the record's own id (ExternalID) into the LAST path var and any parent vars from
|
|
352
|
+
* Attributes/Relationships, then POSTs the wrapped body (Eventbrite uses POST, not PATCH/PUT, for update).
|
|
353
|
+
*/
|
|
354
|
+
async UpdateRecord(ctx) {
|
|
355
|
+
const ci = ctx.CompanyIntegration;
|
|
356
|
+
const contextUser = ctx.ContextUser;
|
|
357
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
358
|
+
if (!obj.UpdateAPIPath || !obj.UpdateMethod) {
|
|
359
|
+
return { Success: false, StatusCode: 0, ErrorMessage: `[eventbrite] UpdateRecord not supported for "${ctx.ObjectName}": UpdateAPIPath / UpdateMethod not configured.` };
|
|
360
|
+
}
|
|
361
|
+
const resolved = this.SubstituteAllPathVars(obj.UpdateAPIPath, ctx.ExternalID, ctx.Attributes, ctx.Relationships);
|
|
362
|
+
if (resolved.unresolved.length > 0) {
|
|
363
|
+
return this.UnresolvedVarError(ctx.ObjectName, 'update', obj.UpdateAPIPath, resolved.unresolved);
|
|
364
|
+
}
|
|
365
|
+
const auth = await this.Authenticate(ci, contextUser);
|
|
366
|
+
const headers = this.BuildHeaders(auth);
|
|
367
|
+
const url = `${this.GetBaseURL(ci)}${resolved.path}`;
|
|
368
|
+
const body = this.BuildOperationBody(ctx.Attributes, obj.UpdateBodyShape, obj.UpdateBodyKey);
|
|
369
|
+
const response = await this.MakeHTTPRequest(auth, url, obj.UpdateMethod, headers, body);
|
|
370
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
371
|
+
return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
|
|
372
|
+
}
|
|
373
|
+
return { Success: false, StatusCode: response.Status,
|
|
374
|
+
ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on update` };
|
|
420
375
|
}
|
|
421
|
-
// ─── Config parsing ───────────────────────────────────────────────
|
|
422
376
|
/**
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
377
|
+
* Delete: substitutes the record's own id (ExternalID) into the last path var and any parent vars, then
|
|
378
|
+
* issues DeleteMethod (metadata-driven — Eventbrite uses hard DELETE, but the verb is read from metadata,
|
|
379
|
+
* not assumed).
|
|
426
380
|
*/
|
|
427
|
-
async
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
// Credential's Token wins; host override + tunables come from Configuration.
|
|
434
|
-
merged.Token = (fromCredential?.Token ?? fromConfig.Token);
|
|
435
|
-
merged.ApiBaseUrl = merged.ApiBaseUrl ?? fromConfig.ApiBaseUrl;
|
|
436
|
-
merged.RequestTimeoutMs = merged.RequestTimeoutMs ?? fromConfig.RequestTimeoutMs;
|
|
437
|
-
merged.MaxRetries = merged.MaxRetries ?? fromConfig.MaxRetries;
|
|
438
|
-
merged.MinRequestIntervalMs = merged.MinRequestIntervalMs ?? fromConfig.MinRequestIntervalMs;
|
|
439
|
-
if (!merged.Token) {
|
|
440
|
-
throw new Error('EventbriteConnector: a Bearer token (PrivateToken / Token / AccessToken / apiKey) ' +
|
|
441
|
-
'must be provided via the credential store or CompanyIntegration.Configuration.');
|
|
442
|
-
}
|
|
443
|
-
return merged;
|
|
444
|
-
}
|
|
445
|
-
/** Resolves the API host: the non-secret override wins over the production host. */
|
|
446
|
-
resolveBaseUrl(config) {
|
|
447
|
-
const raw = (config.ApiBaseUrl ?? PROD_API_HOST).trim();
|
|
448
|
-
return raw.replace(/\/+$/, '');
|
|
449
|
-
}
|
|
450
|
-
/** Parses the non-secret host/tunables config from CompanyIntegration.Configuration JSON. */
|
|
451
|
-
parseConfigurationJson(raw) {
|
|
452
|
-
if (!raw || raw.trim().length === 0)
|
|
453
|
-
return {};
|
|
454
|
-
let parsed;
|
|
455
|
-
try {
|
|
456
|
-
parsed = JSON.parse(raw);
|
|
381
|
+
async DeleteRecord(ctx) {
|
|
382
|
+
const ci = ctx.CompanyIntegration;
|
|
383
|
+
const contextUser = ctx.ContextUser;
|
|
384
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
385
|
+
if (!obj.DeleteAPIPath || !obj.DeleteMethod) {
|
|
386
|
+
return { Success: false, StatusCode: 0, ErrorMessage: `[eventbrite] DeleteRecord not supported for "${ctx.ObjectName}": DeleteAPIPath / DeleteMethod not configured.` };
|
|
457
387
|
}
|
|
458
|
-
|
|
459
|
-
|
|
388
|
+
// Delete carries no Attributes; parent vars (if any) must ride the composite ExternalID as
|
|
389
|
+
// "parentId|recordId" (mirrors the base composite-PK ExternalID form) or be absent (single-var path).
|
|
390
|
+
const { parentTags, recordID } = this.SplitCompositeExternalID(ctx.ExternalID, obj.DeleteAPIPath);
|
|
391
|
+
const resolved = this.SubstituteAllPathVars(obj.DeleteAPIPath, recordID, parentTags, undefined);
|
|
392
|
+
if (resolved.unresolved.length > 0) {
|
|
393
|
+
return this.UnresolvedVarError(ctx.ObjectName, 'delete', obj.DeleteAPIPath, resolved.unresolved);
|
|
394
|
+
}
|
|
395
|
+
const auth = await this.Authenticate(ci, contextUser);
|
|
396
|
+
const headers = this.BuildHeaders(auth);
|
|
397
|
+
const url = `${this.GetBaseURL(ci)}${resolved.path}`;
|
|
398
|
+
const response = await this.MakeHTTPRequest(auth, url, obj.DeleteMethod, headers);
|
|
399
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
400
|
+
return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
|
|
460
401
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
402
|
+
return { Success: false, StatusCode: response.Status,
|
|
403
|
+
ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on delete` };
|
|
404
|
+
}
|
|
405
|
+
// ── Connection test ──────────────────────────────────────────────
|
|
406
|
+
/**
|
|
407
|
+
* Tests the connection by hitting the current-user endpoint (`/users/me/`). A 2xx confirms the Bearer
|
|
408
|
+
* token is valid; 401/403 → auth failure; anything else → error.
|
|
409
|
+
*/
|
|
410
|
+
async TestConnection(companyIntegration, contextUser) {
|
|
411
|
+
try {
|
|
412
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
413
|
+
const headers = this.BuildHeaders(auth);
|
|
414
|
+
const url = `${EVENTBRITE_API_BASE}/users/me/`;
|
|
415
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
416
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
417
|
+
return { Success: true, Message: 'Eventbrite connection successful.' };
|
|
466
418
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const num = (...keys) => {
|
|
470
|
-
for (const k of keys) {
|
|
471
|
-
const hit = Object.entries(parsed).find(([key]) => key.toLowerCase() === k.toLowerCase());
|
|
472
|
-
if (hit && typeof hit[1] === 'number')
|
|
473
|
-
return hit[1];
|
|
419
|
+
if (response.Status === 401 || response.Status === 403) {
|
|
420
|
+
return { Success: false, Message: `Eventbrite authentication failed (HTTP ${response.Status}). Check the OAuth2 Bearer / Private Token.` };
|
|
474
421
|
}
|
|
475
|
-
return
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
422
|
+
return { Success: false, Message: `Eventbrite connection test returned HTTP ${response.Status}.` };
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
426
|
+
return { Success: false, Message: `Eventbrite connection test error: ${msg}` };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// ── Credential loading ───────────────────────────────────────────
|
|
430
|
+
/** Reads the Bearer token from the linked Credential entity, or the Configuration JSON fallback. */
|
|
431
|
+
async LoadCredentials(companyIntegration, contextUser) {
|
|
432
|
+
const credentialID = companyIntegration.CredentialID;
|
|
433
|
+
if (credentialID) {
|
|
434
|
+
const creds = await this.LoadFromCredentialEntity(credentialID, contextUser);
|
|
435
|
+
if (creds)
|
|
436
|
+
return creds;
|
|
437
|
+
}
|
|
438
|
+
const configJson = companyIntegration.Configuration;
|
|
439
|
+
if (configJson) {
|
|
440
|
+
const creds = this.ParseCredentialJson(configJson);
|
|
441
|
+
if (creds)
|
|
442
|
+
return creds;
|
|
443
|
+
}
|
|
444
|
+
throw new Error('No Eventbrite credential found. Attach a credential carrying an OAuth2 Bearer / Private Token ' +
|
|
445
|
+
'(accessToken / apiKey / Token), or set Configuration JSON on the CompanyIntegration.');
|
|
486
446
|
}
|
|
487
|
-
/** Loads
|
|
488
|
-
async
|
|
489
|
-
const md =
|
|
447
|
+
/** Loads a credential row and parses its Values JSON. */
|
|
448
|
+
async LoadFromCredentialEntity(credentialID, contextUser) {
|
|
449
|
+
const md = new Metadata();
|
|
490
450
|
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
491
451
|
const loaded = await credential.Load(credentialID);
|
|
492
452
|
if (!loaded || !credential.Values)
|
|
493
453
|
return null;
|
|
494
|
-
|
|
454
|
+
return this.ParseCredentialJson(credential.Values);
|
|
455
|
+
}
|
|
456
|
+
/** Extracts an Eventbrite token from a credential/config JSON string. Returns null when no token is present. */
|
|
457
|
+
ParseCredentialJson(json) {
|
|
495
458
|
try {
|
|
496
|
-
|
|
459
|
+
const parsed = JSON.parse(json);
|
|
460
|
+
const token = this.FirstString(parsed, ['accessToken', 'AccessToken', 'apiKey', 'ApiKey', 'Token', 'token', 'privateToken', 'PrivateToken']);
|
|
461
|
+
return token ? { AccessToken: token } : null;
|
|
497
462
|
}
|
|
498
463
|
catch {
|
|
499
464
|
return null;
|
|
500
465
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
466
|
+
}
|
|
467
|
+
// ── Path-var substitution helpers ────────────────────────────────
|
|
468
|
+
/**
|
|
469
|
+
* Substitutes EVERY `{var}` in a write path. The record's own id (recordID, when provided) fills the
|
|
470
|
+
* generic `{ID}`/`{id}`/`{ExternalID}` placeholders AND the LAST vendor-named var in the path (the
|
|
471
|
+
* record's own id segment for update/delete, e.g. `{ticket_class_id}` in
|
|
472
|
+
* `/events/{event_id}/ticket_classes/{ticket_class_id}/`). Every OTHER var is a parent id resolved from
|
|
473
|
+
* the resolution map (Attributes ∪ Relationships), matched case-insensitively. Returns the resolved path
|
|
474
|
+
* plus the list of vars that could NOT be resolved (caller fails loudly on a non-empty list).
|
|
475
|
+
*/
|
|
476
|
+
SubstituteAllPathVars(path, recordID, attributes, relationships) {
|
|
477
|
+
const vars = this.DetectPathVars(path);
|
|
478
|
+
if (vars.length === 0)
|
|
479
|
+
return { path, unresolved: [] };
|
|
480
|
+
const genericIDNames = new Set(['id', 'ID', 'ExternalID']);
|
|
481
|
+
// The record's own id var is the LAST non-generic var, when a recordID is supplied (update/delete).
|
|
482
|
+
const nonGeneric = vars.filter(v => !genericIDNames.has(v));
|
|
483
|
+
const ownIDVar = recordID != null && nonGeneric.length > 0 ? nonGeneric[nonGeneric.length - 1] : null;
|
|
484
|
+
const resolutionMap = this.BuildResolutionMap(attributes, relationships);
|
|
485
|
+
const unresolved = [];
|
|
486
|
+
let resolvedPath = path;
|
|
487
|
+
for (const v of vars) {
|
|
488
|
+
let value;
|
|
489
|
+
if (genericIDNames.has(v) || v === ownIDVar) {
|
|
490
|
+
value = recordID;
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
value = resolutionMap.get(v.toLowerCase());
|
|
494
|
+
}
|
|
495
|
+
if (value == null || value.length === 0) {
|
|
496
|
+
unresolved.push(v);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
resolvedPath = resolvedPath.replace(`{${v}}`, encodeURIComponent(value));
|
|
500
|
+
}
|
|
501
|
+
return { path: resolvedPath, unresolved };
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* A delete carries no Attributes, so a child-object delete path with a parent var (e.g.
|
|
505
|
+
* `/events/{event_id}/ticket_classes/{...}`) has no place to source the parent id — EXCEPT the composite
|
|
506
|
+
* ExternalID. When the path has >1 var, the ExternalID is expected as `parentId|...|recordId` (the base's
|
|
507
|
+
* composite-PK ExternalID form): the trailing segment is the record id, the leading segments fill the
|
|
508
|
+
* parent vars in path order. When the path has ≤1 var, the whole ExternalID is the record id.
|
|
509
|
+
*/
|
|
510
|
+
SplitCompositeExternalID(externalID, path) {
|
|
511
|
+
const vars = this.DetectPathVars(path).filter(v => !['id', 'ID', 'ExternalID'].includes(v));
|
|
512
|
+
if (vars.length <= 1) {
|
|
513
|
+
return { parentTags: {}, recordID: externalID };
|
|
514
|
+
}
|
|
515
|
+
const parts = externalID.split('|');
|
|
516
|
+
const recordID = parts[parts.length - 1];
|
|
517
|
+
const parentTags = {};
|
|
518
|
+
// Leading parts map to the leading (parent) vars in path order.
|
|
519
|
+
const parentVars = vars.slice(0, vars.length - 1);
|
|
520
|
+
for (let i = 0; i < parentVars.length && i < parts.length - 1; i++) {
|
|
521
|
+
parentTags[parentVars[i]] = parts[i];
|
|
522
|
+
}
|
|
523
|
+
return { parentTags, recordID };
|
|
524
|
+
}
|
|
525
|
+
/** Detects `{var}` placeholders in a path. */
|
|
526
|
+
DetectPathVars(path) {
|
|
527
|
+
const matches = path.match(/\{(\w+)\}/g);
|
|
528
|
+
return matches ? matches.map(m => m.slice(1, -1)) : [];
|
|
529
|
+
}
|
|
530
|
+
/** Builds a case-insensitive lookup map of parent-id candidates from Attributes ∪ Relationships. */
|
|
531
|
+
BuildResolutionMap(attributes, relationships) {
|
|
532
|
+
const map = new Map();
|
|
533
|
+
const add = (src) => {
|
|
534
|
+
if (!src)
|
|
535
|
+
return;
|
|
536
|
+
for (const [k, v] of Object.entries(src)) {
|
|
537
|
+
if (v == null)
|
|
538
|
+
continue;
|
|
539
|
+
const s = String(v);
|
|
540
|
+
if (s.length > 0)
|
|
541
|
+
map.set(k.toLowerCase(), s);
|
|
506
542
|
}
|
|
507
|
-
return undefined;
|
|
508
543
|
};
|
|
544
|
+
add(relationships);
|
|
545
|
+
add(attributes); // attributes win over relationships on a key collision
|
|
546
|
+
return map;
|
|
547
|
+
}
|
|
548
|
+
/** Builds a consistent unresolved-var CRUD failure result (never a broken URL to the wire). */
|
|
549
|
+
UnresolvedVarError(objectName, verb, path, unresolved) {
|
|
509
550
|
return {
|
|
510
|
-
|
|
551
|
+
Success: false,
|
|
552
|
+
StatusCode: 0,
|
|
553
|
+
ErrorMessage: `[eventbrite] ${verb} for "${objectName}" could not resolve path variable(s) ` +
|
|
554
|
+
`[${unresolved.join(', ')}] in "${path}" — supply them in Attributes/Relationships ` +
|
|
555
|
+
`(parent ids) or as the record ExternalID.`,
|
|
511
556
|
};
|
|
512
557
|
}
|
|
558
|
+
// ── Watermark helpers ────────────────────────────────────────────
|
|
559
|
+
/**
|
|
560
|
+
* Formats a watermark value into the `changed_since` datetime the API expects (UTC ISO-8601). Accepts an
|
|
561
|
+
* ISO string (passed through) or an epoch-ms string (converted). Eventbrite documents `changed_since` as
|
|
562
|
+
* a UTC datetime; passing an unparseable value through unchanged lets the API reject it loudly rather than
|
|
563
|
+
* silently widening the window.
|
|
564
|
+
*/
|
|
565
|
+
FormatChangedSince(watermark) {
|
|
566
|
+
if (/^\d+$/.test(watermark)) {
|
|
567
|
+
return new Date(Number(watermark)).toISOString();
|
|
568
|
+
}
|
|
569
|
+
return watermark;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Computes the new watermark = the max `changed` timestamp across the fetched records, floored at the
|
|
573
|
+
* incoming watermark so it never regresses. Records that don't carry the field are skipped. Returns the
|
|
574
|
+
* incoming watermark unchanged when no record advances it (idempotent no-op next run).
|
|
575
|
+
*/
|
|
576
|
+
MaxWatermark(result, watermarkField, incoming) {
|
|
577
|
+
let maxMs = this.ToMs(incoming);
|
|
578
|
+
let maxStr = incoming;
|
|
579
|
+
for (const rec of result.Records) {
|
|
580
|
+
const raw = rec.Fields?.[watermarkField];
|
|
581
|
+
if (raw == null)
|
|
582
|
+
continue;
|
|
583
|
+
const s = String(raw);
|
|
584
|
+
const ms = this.ToMs(s);
|
|
585
|
+
if (ms > maxMs) {
|
|
586
|
+
maxMs = ms;
|
|
587
|
+
maxStr = s;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return maxStr;
|
|
591
|
+
}
|
|
592
|
+
/** Parses a watermark (ISO datetime or epoch-ms string) to epoch ms; 0 when unparseable/empty. */
|
|
593
|
+
ToMs(value) {
|
|
594
|
+
if (!value || value.length === 0)
|
|
595
|
+
return 0;
|
|
596
|
+
if (/^\d+$/.test(value))
|
|
597
|
+
return Number(value);
|
|
598
|
+
const t = Date.parse(value);
|
|
599
|
+
return isNaN(t) ? 0 : t;
|
|
600
|
+
}
|
|
601
|
+
// ── Misc helpers ─────────────────────────────────────────────────
|
|
602
|
+
/** Returns the first present, non-empty string value among the given keys. */
|
|
603
|
+
FirstString(obj, keys) {
|
|
604
|
+
for (const k of keys) {
|
|
605
|
+
const v = obj[k];
|
|
606
|
+
if (typeof v === 'string' && v.length > 0)
|
|
607
|
+
return v;
|
|
608
|
+
}
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Resolves an IO by name from the engine cache (via this integration's id) without throwing. Returns null
|
|
613
|
+
* when the cache is unavailable (unit-test context) or the object isn't found — StableOrderingKey then
|
|
614
|
+
* degrades to null, a safe default (the engine simply doesn't use keyset resume for that object).
|
|
615
|
+
*/
|
|
616
|
+
TryGetActiveObject(objectName) {
|
|
617
|
+
try {
|
|
618
|
+
const integ = IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName);
|
|
619
|
+
if (!integ)
|
|
620
|
+
return null;
|
|
621
|
+
return IntegrationEngineBase.Instance.GetIntegrationObject(integ.ID, objectName) ?? null;
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
513
627
|
};
|
|
514
628
|
EventbriteConnector = __decorate([
|
|
629
|
+
RegisterClass(BaseIntegrationConnector, '@memberjunction/connector-eventbrite'),
|
|
515
630
|
RegisterClass(BaseIntegrationConnector, 'EventbriteConnector')
|
|
516
631
|
], EventbriteConnector);
|
|
517
632
|
export { EventbriteConnector };
|
|
518
|
-
/** Tree-shaking prevention function — import and call from the package entry point. */
|
|
519
|
-
export function LoadEventbriteConnector() { }
|
|
520
633
|
/**
|
|
521
634
|
* Minimal bounded promise-pool: runs `worker` over `items` with at most `limit` in flight.
|
|
522
635
|
* (BaseRESTIntegrationConnector.RunBounded is private, so the sample-union override brings its own
|