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