@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.
@@ -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 { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
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 documents a default rate limit of 2,000 calls per hour per token
18
- * (≈0.55/s). Run conservatively under that: ~0.27 tokens/s with a small burst so the
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 RATE_LIMIT_TOKENS_PER_SEC = 0.27;
22
- const RATE_LIMIT_BURST = 10;
23
- // ─── Connector implementation ─────────────────────────────────────────
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
- * Connector for the Eventbrite Platform REST API v3.
31
+ * Eventbrite events/ticketing connector extends BaseRESTIntegrationConnector (REST/JSON over HTTP).
26
32
  *
27
- * Authenticates via a Bearer token (private token or OAuth2 access token, resolved
28
- * upstream and held in the credential store). Reads ride the base
29
- * {@link BaseRESTIntegrationConnector} pull path; this class overrides only the
30
- * genuinely Eventbrite-specific bits:
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 context (token + resolved host). */
58
- this.authState = null;
59
- /** Timestamp of the last outbound request, used for throttling. */
60
- this.lastRequestTime = 0;
61
- }
62
- // ── Identity + capability getters ───────────────────────────────────
63
- /** Verbatim from the metadata Integration row part of the three-way name invariant. */
64
- get IntegrationName() { return 'Eventbrite'; }
65
- // Eventbrite exposes a documented write API for a subset of objects (Events, Ticket
66
- // Classes, Venues, Discounts, Questions, Webhooks, Ticket Groups, Inventory Tiers, Event
67
- // Teams, Media). The generic per-operation path enforces null-capability honesty per IO
68
- // (an IO with no CreateAPIPath throws on create), so these getters reflect that SOME IOs
69
- // are writable; the actual writable set is each IO's per-op columns in the metadata.
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
- * Cap how many EVENT/ORG parents a second-layer object (TicketClass, Attendee, Question,
83
- * EventTeam, InventoryTier, …) iterates per FetchChanges call. At Eventbrite's deliberately
84
- * conservative 0.27 tok/s (~3.7 s/request once the SHARED burst is spent by earlier objects in
85
- * the same sync), the base default of 10 parents costs ~37 s and blows the 30 s FetchChanges
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
- TemplateVarParentBatchSize() {
91
- return 4;
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
- // ─── FetchChanges (watermark-aware) ───────────────────────────────
109
+ // ── Sync-efficiency hooks (§7/§10 — populated from frozen-contract Configuration facts) ──
175
110
  /**
176
- * Sets the watermark context that {@link BuildPaginatedURL} emits as the IO's
177
- * incremental `changed_since` param (for Order/Attendee), delegates the actual fetch +
178
- * cursor pagination to the base, then advances the watermark from the returned records'
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
- async FetchChanges(ctx) {
183
- this.currentWatermark = ctx.WatermarkValue ?? undefined;
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
- * Scans a batch for the latest watermark value. Eventbrite's incremental objects
196
- * (Order, Attendee) carry a `changed` ISO-8601 timestamp per record (the field named by
197
- * the IO's `IncrementalWatermarkField`); we take the max so the next run's `changed_since`
198
- * resumes from there.
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
- ExtractLatestWatermark(records, watermarkField) {
201
- let latest = null;
202
- for (const rec of records) {
203
- const raw = rec.Fields?.[watermarkField];
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
- // ─── Auth + transport (abstract base requirements) ────────────────
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.authState)
215
- return this.authState;
216
- const config = await this.parseConfig(companyIntegration, contextUser);
217
- const state = {
218
- Token: config.Token ?? '',
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
- /** Builds the Eventbrite auth header: `Authorization: Bearer <token>` + `Accept: application/json`. */
156
+ /** Eventbrite OAuth2 auth: a pre-minted Bearer token. No signing, no crypto. */
226
157
  BuildHeaders(auth) {
227
- const ebAuth = auth;
158
+ const token = auth.Token;
228
159
  return {
229
- 'Authorization': `Bearer ${ebAuth.Token}`,
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
- * Unwraps Eventbrite's response shapes:
236
- * - List endpoints: `{ <responseDataKey>: [ ... ], pagination: { ... } }` → the named array.
237
- * - Single-record detail (no responseDataKey): a bare object a one-element array.
238
- * - null [].
239
- * The IO metadata's `responseDataKey` (e.g. `events`/`attendees`/`orders`) is the
240
- * authoritative envelope key; when absent the body is treated as a bare detail object.
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
- const asObj = this.asObject(rawBody);
246
- // Explicit metadata-declared envelope key wins (the list case).
247
- if (responseDataKey && asObj) {
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
- if (asObj) {
254
- // Bare single-record detail object (no responseDataKey, e.g. User / Media / Organizer).
255
- return [asObj];
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
- // Bare array fallback (defensive Eventbrite list shapes are always keyed).
258
- if (Array.isArray(rawBody))
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
- * Builds the paginated request URL. Eventbrite cursor pagination uses `?continuation=<token>`
300
- * (the base default emits `?cursor=...&limit=...`), so we override to emit the vendor's
301
- * param. Also emits the IO's `IncrementalWatermarkField` as `changed_since=<watermark>`
302
- * when this is an incremental object (Order/Attendee) and a watermark is in context —
303
- * fully metadata-driven (read `obj.IncrementalWatermarkField` + `obj.SupportsIncrementalSync`),
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
- BuildPaginatedURL(basePath, obj, _page, _offset, cursor, _effectivePageSize) {
310
- const separator = basePath.includes('?') ? '&' : '?';
311
- const params = new URLSearchParams();
312
- // Incremental param (changed_since) for objects that declare a watermark field.
313
- const watermarkParam = this.resolveWatermarkParam(obj);
314
- if (obj.SupportsIncrementalSync && watermarkParam && this.currentWatermark) {
315
- params.set(watermarkParam, this.currentWatermark);
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
- const qs = params.toString();
322
- return qs.length > 0 ? `${basePath}${separator}${qs}` : basePath;
237
+ return { HasMore: false };
323
238
  }
324
239
  /**
325
- * Resolves the vendor-side query param name for an incremental object. Eventbrite's
326
- * incremental filter param is `changed_since` (the IO's `IncrementalWatermarkField` is the
327
- * RECORD field `changed`). Metadata-driven: only objects whose `SupportsIncrementalSync` is
328
- * true and whose watermark field is set receive the param.
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
- resolveWatermarkParam(obj) {
331
- if (!obj.SupportsIncrementalSync || !obj.IncrementalWatermarkField)
332
- return undefined;
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 resp = await this.doFetch(url, method, headers, body, timeoutMs);
349
- this.lastRequestTime = Date.now();
350
- if ((resp.Status === 429 || resp.Status === 503) && attempt < maxRetries) {
351
- await this.sleep(this.backoffFromResponse(resp, attempt));
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 (err) {
357
- if (attempt === maxRetries)
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
- throw new Error(`Eventbrite request to ${url} exhausted ${maxRetries + 1} attempts`);
260
+ return EVENTBRITE_API_BASE;
365
261
  }
366
- /** Single fetch() with an AbortController-backed timeout. */
367
- async doFetch(url, method, headers, body, timeoutMs) {
368
- const controller = new AbortController();
369
- const handle = setTimeout(() => controller.abort(), timeoutMs);
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 resp = await fetch(url, {
372
- method,
373
- headers,
374
- body: body !== undefined && method !== 'GET' ? JSON.stringify(body) : undefined,
375
- signal: controller.signal,
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
- clearTimeout(handle);
311
+ this.activeChangedSince = null;
385
312
  }
386
313
  }
387
- safeParseJSON(text) {
388
- try {
389
- return JSON.parse(text);
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
- catch {
392
- return text;
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
- isRetryableError(err) {
396
- const msg = err instanceof Error ? err.message : String(err);
397
- return /abort|timeout|ECONNRESET|ENOTFOUND|ETIMEDOUT|ECONNREFUSED|fetch failed|network/i.test(msg);
398
- }
399
- backoffMs(attempt) {
400
- const base = Math.min(1000 * Math.pow(2, attempt), 20000);
401
- const jitter = Math.floor(Math.random() * 500);
402
- return base + jitter;
403
- }
404
- backoffFromResponse(resp, attempt) {
405
- const fromHeader = this.ExtractRetryAfterMs({ Headers: resp.Headers });
406
- if (fromHeader != null)
407
- return Math.min(fromHeader, 30000);
408
- return this.backoffMs(attempt);
409
- }
410
- async throttle(minIntervalMs) {
411
- const elapsed = Date.now() - this.lastRequestTime;
412
- if (elapsed < minIntervalMs)
413
- await this.sleep(minIntervalMs - elapsed);
414
- }
415
- sleep(ms) {
416
- return new Promise(resolve => setTimeout(resolve, ms));
417
- }
418
- asObject(node) {
419
- return node && typeof node === 'object' && !Array.isArray(node) ? node : undefined;
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
- * Resolves the connection config: the Bearer token from the credential store; the
424
- * non-secret host override + transport tunables from CompanyIntegration.Configuration.
425
- * Secrets are NEVER baked into code.
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 parseConfig(companyIntegration, contextUser) {
428
- const fromCredential = companyIntegration.CredentialID
429
- ? await this.loadFromCredential(companyIntegration.CredentialID, contextUser)
430
- : null;
431
- const fromConfig = this.parseConfigurationJson(companyIntegration.Configuration);
432
- const merged = { ...fromConfig, ...fromCredential };
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
- catch {
459
- throw new Error('EventbriteConnector: CompanyIntegration.Configuration is not valid JSON.');
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
- const str = (...keys) => {
462
- for (const k of keys) {
463
- const hit = Object.entries(parsed).find(([key]) => key.toLowerCase() === k.toLowerCase());
464
- if (hit && typeof hit[1] === 'string' && hit[1].length > 0)
465
- return hit[1];
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
- return undefined;
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 undefined;
476
- };
477
- return {
478
- // A token MAY be carried in Configuration for credential-free replay harnesses;
479
- // the credential store remains preferred (parseConfig gives fromCredential precedence).
480
- Token: str('PrivateToken', 'Token', 'AccessToken', 'accessToken', 'apiKey', 'api_key'),
481
- ApiBaseUrl: str('apiBaseUrl', 'api_base_url', 'APIBaseURL', 'baseUrl', 'base_url'),
482
- RequestTimeoutMs: num('requestTimeoutMs'),
483
- MaxRetries: num('maxRetries'),
484
- MinRequestIntervalMs: num('minRequestIntervalMs'),
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 the Bearer token from the MJ credential store. */
488
- async loadFromCredential(credentialID, contextUser, provider) {
489
- const md = provider ?? new Metadata();
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
- let raw;
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
- raw = JSON.parse(credential.Values);
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
- const get = (...keys) => {
502
- for (const k of keys) {
503
- const hit = Object.entries(raw).find(([key]) => key.toLowerCase() === k.toLowerCase());
504
- if (hit && typeof hit[1] === 'string')
505
- return hit[1];
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
- Token: get('PrivateToken', 'Token', 'AccessToken', 'accessToken', 'apiKey', 'api_key', 'key'),
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