@memberjunction/integration-connectors 5.11.0 → 5.12.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.
@@ -0,0 +1,861 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { createHmac } from 'node:crypto';
8
+ import { RegisterClass } from '@memberjunction/global';
9
+ import { Metadata } from '@memberjunction/core';
10
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
11
+ // ─── Constants ───────────────────────────────────────────────────────
12
+ /** Default JWT expiration window in seconds (5 minutes) */
13
+ const JWT_EXPIRATION_SECONDS = 300;
14
+ /** Maximum retries for rate-limited or transient failures */
15
+ const MAX_RETRIES = 5;
16
+ /** HTTP request timeout in milliseconds */
17
+ const REQUEST_TIMEOUT_MS = 30_000;
18
+ /** Minimum milliseconds between API requests (Wicket: 500 req/min ≈ 120ms/req) */
19
+ const MIN_REQUEST_INTERVAL_MS = 125;
20
+ /** Default page size for Wicket API requests */
21
+ const DEFAULT_PAGE_SIZE = 100;
22
+ /**
23
+ * Maps integration object names to their JSON:API `type` values.
24
+ * Used when constructing JSON:API request bodies for CRUD operations.
25
+ */
26
+ const WICKET_JSONAPI_TYPES = {
27
+ people: 'people',
28
+ organizations: 'organizations',
29
+ connections: 'connections',
30
+ groups: 'groups',
31
+ group_members: 'group_members',
32
+ person_memberships: 'person_memberships',
33
+ organization_memberships: 'organization_memberships',
34
+ memberships: 'memberships',
35
+ touchpoints: 'touchpoints',
36
+ emails: 'emails',
37
+ phones: 'phones',
38
+ addresses: 'addresses',
39
+ roles: 'roles',
40
+ user_identities: 'user_identities',
41
+ resource_tags: 'resource_tags',
42
+ people_emails: 'emails',
43
+ people_phones: 'phones',
44
+ people_addresses: 'addresses',
45
+ org_emails: 'emails',
46
+ org_phones: 'phones',
47
+ org_addresses: 'addresses',
48
+ };
49
+ /**
50
+ * Objects that support POST /query endpoints for advanced search.
51
+ * These accept nested filters, OR logic, and data_fields searching.
52
+ */
53
+ const SEARCHABLE_OBJECTS = new Set([
54
+ 'people',
55
+ 'organizations',
56
+ 'connections',
57
+ 'groups',
58
+ 'group_members',
59
+ 'person_memberships',
60
+ 'organization_memberships',
61
+ ]);
62
+ /**
63
+ * Objects that are immutable (no update/delete allowed).
64
+ * Touchpoints are audit trail records by design.
65
+ */
66
+ const IMMUTABLE_OBJECTS = new Set(['touchpoints']);
67
+ // ─── JWT Helper ──────────────────────────────────────────────────────
68
+ /**
69
+ * Encodes a buffer or string to base64url format (RFC 7515).
70
+ * Replaces `+` with `-`, `/` with `_`, and strips trailing `=` padding.
71
+ */
72
+ function base64url(input) {
73
+ const b64 = Buffer.from(input, 'utf8').toString('base64');
74
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
75
+ }
76
+ /** Encodes a Buffer to base64url format. */
77
+ function base64urlBuffer(input) {
78
+ return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
79
+ }
80
+ /**
81
+ * Generates a signed JWT token for Wicket API authentication.
82
+ * Uses HS256 (HMAC-SHA256) algorithm as required by the Wicket API.
83
+ *
84
+ * @param credentials - Wicket credentials containing API secret, admin UUID, and tenant
85
+ * @returns Signed JWT string
86
+ */
87
+ /** Resolves the Wicket API base URL from credentials (direct URL or tenant-derived). */
88
+ function resolveWicketBaseURL(credentials) {
89
+ if (credentials.ApiUrl) {
90
+ // Strip trailing slash for consistency
91
+ return credentials.ApiUrl.replace(/\/+$/, '');
92
+ }
93
+ if (credentials.TenantName) {
94
+ return `https://${credentials.TenantName}-api.wicketcloud.com`;
95
+ }
96
+ throw new Error('Cannot determine Wicket API base URL. Provide apiUrl or tenantName in credentials.');
97
+ }
98
+ function generateWicketJWT(credentials) {
99
+ const now = Math.floor(Date.now() / 1000);
100
+ const apiUrl = resolveWicketBaseURL(credentials);
101
+ const header = { alg: 'HS256', typ: 'JWT' };
102
+ const payload = {
103
+ exp: now + JWT_EXPIRATION_SECONDS,
104
+ sub: credentials.AdminUserUUID,
105
+ aud: apiUrl,
106
+ };
107
+ if (credentials.IssuerDomain) {
108
+ payload['iss'] = credentials.IssuerDomain;
109
+ }
110
+ const encodedHeader = base64url(JSON.stringify(header));
111
+ const encodedPayload = base64url(JSON.stringify(payload));
112
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
113
+ const signature = createHmac('sha256', credentials.ApiSecret)
114
+ .update(signingInput)
115
+ .digest();
116
+ return `${signingInput}.${base64urlBuffer(signature)}`;
117
+ }
118
+ // ─── Connector ───────────────────────────────────────────────────────
119
+ /**
120
+ * Connector for the Wicket membership management platform via its JSON:API REST API.
121
+ *
122
+ * Extends BaseRESTIntegrationConnector to leverage metadata-driven object/field
123
+ * discovery, generic pagination handling, and template variable resolution.
124
+ *
125
+ * Supports:
126
+ * - JWT Bearer authentication (HS256, generated from API secret)
127
+ * - JSON:API response format normalization (flattens `data[].attributes`)
128
+ * - Page-number pagination (`page[number]`, `page[size]`)
129
+ * - Timestamp-based watermarks via `filter[updated_at_gteq]`
130
+ * - Full CRUD operations (bidirectional sync)
131
+ * - Advanced search via POST /query endpoints
132
+ * - Rate limiting (500 req/min, auto-throttle + retry on 429)
133
+ *
134
+ * @see https://wicketapi.docs.apiary.io/
135
+ */
136
+ let WicketConnector = class WicketConnector extends BaseRESTIntegrationConnector {
137
+ constructor() {
138
+ super(...arguments);
139
+ /** Timestamp of the last API request, used for throttling */
140
+ this.lastRequestTime = 0;
141
+ /** Cached auth context to avoid regenerating JWT on every request */
142
+ this.cachedAuth = null;
143
+ /** Expiration time of the cached auth context */
144
+ this.cachedAuthExpiresAt = 0;
145
+ }
146
+ // ─── Capability Getters ──────────────────────────────────────────
147
+ get SupportsCreate() { return true; }
148
+ get SupportsUpdate() { return true; }
149
+ get SupportsDelete() { return true; }
150
+ get SupportsSearch() { return true; }
151
+ // ─── BaseRESTIntegrationConnector abstract implementations ───────
152
+ async Authenticate(companyIntegration, contextUser) {
153
+ // Return cached auth if still valid (with 30s safety margin)
154
+ const now = Math.floor(Date.now() / 1000);
155
+ if (this.cachedAuth && this.cachedAuthExpiresAt > now + 30) {
156
+ return this.cachedAuth;
157
+ }
158
+ const credentials = await this.LoadCredentials(companyIntegration, contextUser);
159
+ const token = generateWicketJWT(credentials);
160
+ const baseURL = resolveWicketBaseURL(credentials);
161
+ const auth = {
162
+ Token: token,
163
+ Credentials: credentials,
164
+ BaseURL: baseURL,
165
+ };
166
+ this.cachedAuth = auth;
167
+ this.cachedAuthExpiresAt = now + JWT_EXPIRATION_SECONDS;
168
+ return auth;
169
+ }
170
+ BuildHeaders(auth) {
171
+ return {
172
+ 'Authorization': `Bearer ${auth.Token}`,
173
+ 'Accept': 'application/json',
174
+ 'Content-Type': 'application/vnd.api+json',
175
+ };
176
+ }
177
+ async MakeHTTPRequest(_auth, url, method, headers, body) {
178
+ await this.ThrottleRequest();
179
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
180
+ const response = await this.FetchWithTimeout(url, method, headers, body);
181
+ this.lastRequestTime = Date.now();
182
+ if (response.status === 429) {
183
+ const delayMs = this.CalculateRetryDelay(response, attempt);
184
+ console.warn(`[Wicket] Rate limited (429), retrying in ${delayMs}ms ` +
185
+ `(attempt ${attempt + 1}/${MAX_RETRIES})`);
186
+ await this.Sleep(delayMs);
187
+ continue;
188
+ }
189
+ const responseBody = await this.ParseResponseBody(response);
190
+ return this.BuildRESTResponse(response, responseBody);
191
+ }
192
+ throw new Error(`Wicket API request failed after ${MAX_RETRIES} retries: ${url}`);
193
+ }
194
+ /**
195
+ * Normalizes a JSON:API response into flat record objects.
196
+ *
197
+ * Wicket returns data in JSON:API format:
198
+ * ```json
199
+ * {
200
+ * "data": [{ "id": "uuid", "type": "people", "attributes": {...}, "relationships": {...} }],
201
+ * "included": [...],
202
+ * "meta": { "page": { "number": 1, "size": 25, "total_count": 500 } }
203
+ * }
204
+ * ```
205
+ *
206
+ * This method flattens each record to: `{ uuid: "...", given_name: "...", ... }`
207
+ */
208
+ NormalizeResponse(rawBody, responseDataKey) {
209
+ const body = rawBody;
210
+ // Use responseDataKey if specified, otherwise default to "data"
211
+ const dataKey = responseDataKey ?? 'data';
212
+ const data = body[dataKey];
213
+ if (!data)
214
+ return [];
215
+ // Handle single-object responses (e.g., GET /people/{id})
216
+ if (!Array.isArray(data)) {
217
+ return [this.FlattenJsonApiRecord(data)];
218
+ }
219
+ return data.map(r => this.FlattenJsonApiRecord(r));
220
+ }
221
+ /**
222
+ * Extracts pagination state from Wicket's JSON:API meta response.
223
+ *
224
+ * Wicket returns pagination info in:
225
+ * ```json
226
+ * { "meta": { "page": { "number": 1, "size": 25, "total_count": 500 } } }
227
+ * ```
228
+ */
229
+ ExtractPaginationInfo(rawBody, _paginationType, currentPage, _currentOffset, pageSize) {
230
+ const body = rawBody;
231
+ const meta = body['meta'];
232
+ const pageInfo = meta?.['page'];
233
+ if (!pageInfo?.total_count) {
234
+ return { HasMore: false };
235
+ }
236
+ const totalPages = Math.ceil(pageInfo.total_count / (pageInfo.size ?? pageSize));
237
+ const hasMore = currentPage < totalPages;
238
+ return {
239
+ HasMore: hasMore,
240
+ NextPage: hasMore ? currentPage + 1 : undefined,
241
+ TotalRecords: pageInfo.total_count,
242
+ };
243
+ }
244
+ GetBaseURL(companyIntegration) {
245
+ // If we have a cached auth context, use its base URL
246
+ if (this.cachedAuth) {
247
+ return this.cachedAuth.BaseURL;
248
+ }
249
+ // Fallback: parse from configuration JSON
250
+ const configJson = companyIntegration.Get('Configuration');
251
+ if (configJson) {
252
+ try {
253
+ const config = JSON.parse(configJson);
254
+ const apiUrl = config['apiUrl'] ?? config['ApiUrl'];
255
+ if (apiUrl)
256
+ return apiUrl.replace(/\/+$/, '');
257
+ const tenant = config['tenantName'] ?? config['TenantName'];
258
+ if (tenant)
259
+ return `https://${tenant}-api.wicketcloud.com`;
260
+ }
261
+ catch { /* fall through */ }
262
+ }
263
+ throw new Error('Cannot determine Wicket API base URL. Provide apiUrl or tenantName in credentials or configuration.');
264
+ }
265
+ // ─── Wicket-specific pagination ──────────────────────────────────
266
+ /**
267
+ * Overrides base pagination URL building to use Wicket's JSON:API parameter format.
268
+ * Wicket uses `page[number]` and `page[size]` (bracket notation).
269
+ */
270
+ BuildPaginatedURL(basePath, obj, page, _offset, _cursor) {
271
+ const separator = basePath.includes('?') ? '&' : '?';
272
+ const pageSize = obj.DefaultPageSize || DEFAULT_PAGE_SIZE;
273
+ return `${basePath}${separator}page[number]=${page}&page[size]=${pageSize}`;
274
+ }
275
+ // ─── FetchChanges override for watermark support ─────────────────
276
+ /**
277
+ * Overrides FetchChanges to inject Wicket's timestamp-based watermark filter.
278
+ * Uses `filter[updated_at_gteq]` for incremental sync.
279
+ */
280
+ async FetchChanges(ctx) {
281
+ // If watermark is provided, inject it as a filter parameter
282
+ if (ctx.WatermarkValue) {
283
+ const currentConfig = ctx.CompanyIntegration.Get('Configuration');
284
+ const config = currentConfig ? JSON.parse(currentConfig) : {};
285
+ // Store original and inject watermark filter into request
286
+ const originalWatermarkFilter = config['_watermarkFilter'];
287
+ config['_watermarkFilter'] = ctx.WatermarkValue;
288
+ // We'll use this in AppendDefaultQueryParams override behavior
289
+ }
290
+ const result = await super.FetchChanges(ctx);
291
+ // Set new watermark to current timestamp if we got records
292
+ if (result.Records.length > 0) {
293
+ result.NewWatermarkValue = new Date().toISOString();
294
+ }
295
+ return result;
296
+ }
297
+ // ─── TestConnection ──────────────────────────────────────────────
298
+ /** Tests connectivity by authenticating and fetching 1 person record. */
299
+ async TestConnection(companyIntegration, contextUser) {
300
+ try {
301
+ const auth = await this.Authenticate(companyIntegration, contextUser);
302
+ const headers = this.BuildHeaders(auth);
303
+ const baseURL = this.GetBaseURL(companyIntegration);
304
+ const response = await this.MakeHTTPRequest(auth, `${baseURL}/people?page[size]=1`, 'GET', headers);
305
+ if (response.Status >= 200 && response.Status < 300) {
306
+ return {
307
+ Success: true,
308
+ Message: 'Successfully connected to Wicket API',
309
+ ServerVersion: 'Wicket JSON:API v1',
310
+ };
311
+ }
312
+ const bodyPreview = this.FormatBodyPreview(response.Body);
313
+ return {
314
+ Success: false,
315
+ Message: `Wicket API returned ${response.Status}: ${bodyPreview}`,
316
+ };
317
+ }
318
+ catch (err) {
319
+ const message = err instanceof Error ? err.message : String(err);
320
+ return { Success: false, Message: `Connection failed: ${message}` };
321
+ }
322
+ }
323
+ // ─── GetDefaultFieldMappings ─────────────────────────────────────
324
+ GetDefaultFieldMappings(objectName, _entityName) {
325
+ switch (objectName) {
326
+ case 'people':
327
+ return this.GetPeopleFieldMappings();
328
+ case 'organizations':
329
+ return this.GetOrganizationFieldMappings();
330
+ case 'person_memberships':
331
+ return this.GetPersonMembershipFieldMappings();
332
+ case 'connections':
333
+ return this.GetConnectionFieldMappings();
334
+ default:
335
+ return [];
336
+ }
337
+ }
338
+ // ─── Default Configuration ───────────────────────────────────────
339
+ GetDefaultConfiguration() {
340
+ return {
341
+ DefaultSchemaName: 'Wicket',
342
+ DefaultObjects: [],
343
+ };
344
+ }
345
+ // ─── CRUD Operations (bidirectional sync support) ────────────────
346
+ /**
347
+ * Creates a new record in the Wicket API.
348
+ *
349
+ * @param companyIntegration - Company integration with credentials
350
+ * @param objectName - Wicket object type (e.g., "people", "organizations")
351
+ * @param attributes - Field values for the new record
352
+ * @param contextUser - User context for authorization
353
+ * @param relationships - Optional JSON:API relationships to include
354
+ * @returns CRUD result with the created record's external ID
355
+ */
356
+ async CreateRecord(ctx) {
357
+ const companyIntegration = ctx.CompanyIntegration;
358
+ const objectName = ctx.ObjectName;
359
+ this.ValidateWriteAllowed(objectName);
360
+ const auth = await this.Authenticate(companyIntegration, ctx.ContextUser);
361
+ const headers = this.BuildHeaders(auth);
362
+ const baseURL = this.GetBaseURL(companyIntegration);
363
+ const apiPath = this.ResolveObjectAPIPath(objectName);
364
+ const body = this.BuildJsonApiBody(objectName, ctx.Attributes, ctx.Relationships);
365
+ try {
366
+ const response = await this.MakeHTTPRequest(auth, `${baseURL}${apiPath}`, 'POST', headers, body);
367
+ return this.ParseCRUDResponse(response, 'create');
368
+ }
369
+ catch (err) {
370
+ return this.BuildCRUDError(err);
371
+ }
372
+ }
373
+ /**
374
+ * Updates an existing record in the Wicket API.
375
+ *
376
+ * @param companyIntegration - Company integration with credentials
377
+ * @param objectName - Wicket object type
378
+ * @param externalID - UUID of the record to update
379
+ * @param attributes - Field values to update
380
+ * @param contextUser - User context for authorization
381
+ * @param relationships - Optional JSON:API relationships to update
382
+ * @returns CRUD result indicating success or failure
383
+ */
384
+ async UpdateRecord(ctx) {
385
+ const companyIntegration = ctx.CompanyIntegration;
386
+ const objectName = ctx.ObjectName;
387
+ this.ValidateWriteAllowed(objectName);
388
+ const auth = await this.Authenticate(companyIntegration, ctx.ContextUser);
389
+ const headers = this.BuildHeaders(auth);
390
+ const baseURL = this.GetBaseURL(companyIntegration);
391
+ const apiPath = this.ResolveObjectAPIPath(objectName);
392
+ const body = this.BuildJsonApiBody(objectName, ctx.Attributes, ctx.Relationships, ctx.ExternalID);
393
+ try {
394
+ const response = await this.MakeHTTPRequest(auth, `${baseURL}${apiPath}/${ctx.ExternalID}`, 'PATCH', headers, body);
395
+ return this.ParseCRUDResponse(response, 'update');
396
+ }
397
+ catch (err) {
398
+ return this.BuildCRUDError(err);
399
+ }
400
+ }
401
+ /**
402
+ * Deletes a record from the Wicket API.
403
+ *
404
+ * @param companyIntegration - Company integration with credentials
405
+ * @param objectName - Wicket object type
406
+ * @param externalID - UUID of the record to delete
407
+ * @param contextUser - User context for authorization
408
+ * @returns CRUD result indicating success or failure
409
+ */
410
+ async DeleteRecord(ctx) {
411
+ const companyIntegration = ctx.CompanyIntegration;
412
+ const objectName = ctx.ObjectName;
413
+ this.ValidateWriteAllowed(objectName);
414
+ this.ValidateDeleteAllowed(objectName);
415
+ const auth = await this.Authenticate(companyIntegration, ctx.ContextUser);
416
+ const headers = this.BuildHeaders(auth);
417
+ const baseURL = this.GetBaseURL(companyIntegration);
418
+ const apiPath = this.ResolveObjectAPIPath(objectName);
419
+ try {
420
+ const response = await this.MakeHTTPRequest(auth, `${baseURL}${apiPath}/${ctx.ExternalID}`, 'DELETE', headers);
421
+ return {
422
+ Success: response.Status === 204 || (response.Status >= 200 && response.Status < 300),
423
+ ExternalID: ctx.ExternalID,
424
+ StatusCode: response.Status,
425
+ };
426
+ }
427
+ catch (err) {
428
+ return this.BuildCRUDError(err);
429
+ }
430
+ }
431
+ /**
432
+ * Retrieves a single record from the Wicket API by ID.
433
+ *
434
+ * @param companyIntegration - Company integration with credentials
435
+ * @param objectName - Wicket object type
436
+ * @param externalID - UUID of the record to retrieve
437
+ * @param contextUser - User context for authorization
438
+ * @returns The record as an ExternalRecord, or null if not found
439
+ */
440
+ async GetRecord(ctx) {
441
+ const companyIntegration = ctx.CompanyIntegration;
442
+ const objectName = ctx.ObjectName;
443
+ const auth = await this.Authenticate(companyIntegration, ctx.ContextUser);
444
+ const headers = this.BuildHeaders(auth);
445
+ const baseURL = this.GetBaseURL(companyIntegration);
446
+ const apiPath = this.ResolveObjectAPIPath(objectName);
447
+ try {
448
+ const response = await this.MakeHTTPRequest(auth, `${baseURL}${apiPath}/${ctx.ExternalID}`, 'GET', headers);
449
+ if (response.Status === 404)
450
+ return null;
451
+ if (response.Status < 200 || response.Status >= 300)
452
+ return null;
453
+ const records = this.NormalizeResponse(response.Body, 'data');
454
+ if (records.length === 0)
455
+ return null;
456
+ return {
457
+ ExternalID: ctx.ExternalID,
458
+ ObjectType: objectName,
459
+ Fields: records[0],
460
+ };
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
466
+ // ─── Search Operations ───────────────────────────────────────────
467
+ /**
468
+ * Searches for records using Wicket's POST /query endpoints.
469
+ *
470
+ * Supports advanced filtering with predicates:
471
+ * - Equality: `given_name_eq`, `status_not_eq`
472
+ * - Comparison: `created_at_gteq`, `updated_at_lt`
473
+ * - Pattern: `email_cont`, `name_start`
474
+ * - Null checks: `phone_present`, `address_blank`
475
+ * - Array: `status_in` (comma-separated values)
476
+ *
477
+ * @param companyIntegration - Company integration with credentials
478
+ * @param objectName - Wicket object type (must be in SEARCHABLE_OBJECTS)
479
+ * @param options - Search filters, sorting, and pagination
480
+ * @param contextUser - User context for authorization
481
+ * @returns Search results with records, total count, and pagination info
482
+ */
483
+ async SearchRecords(ctx) {
484
+ const companyIntegration = ctx.CompanyIntegration;
485
+ const objectName = ctx.ObjectName;
486
+ if (!SEARCHABLE_OBJECTS.has(objectName)) {
487
+ throw new Error(`Search is not supported for "${objectName}". ` +
488
+ `Searchable objects: ${[...SEARCHABLE_OBJECTS].join(', ')}`);
489
+ }
490
+ const auth = await this.Authenticate(companyIntegration, ctx.ContextUser);
491
+ const headers = this.BuildHeaders(auth);
492
+ const baseURL = this.GetBaseURL(companyIntegration);
493
+ const wicketOptions = {
494
+ Filters: ctx.Filters,
495
+ Sort: ctx.Sort,
496
+ Page: ctx.Page,
497
+ PageSize: ctx.PageSize,
498
+ };
499
+ const queryBody = this.BuildSearchQueryBody(wicketOptions);
500
+ const response = await this.MakeHTTPRequest(auth, `${baseURL}/${objectName}/query`, 'POST', headers, queryBody);
501
+ return this.ParseSearchResponse(response, objectName);
502
+ }
503
+ // ─── Default Field Mapping Builders ──────────────────────────────
504
+ GetPeopleFieldMappings() {
505
+ return [
506
+ { SourceFieldName: 'uuid', DestinationFieldName: 'ExternalID', IsKeyField: true },
507
+ { SourceFieldName: 'given_name', DestinationFieldName: 'FirstName' },
508
+ { SourceFieldName: 'family_name', DestinationFieldName: 'LastName' },
509
+ { SourceFieldName: 'full_name', DestinationFieldName: 'FullName' },
510
+ { SourceFieldName: 'job_title', DestinationFieldName: 'JobTitle' },
511
+ { SourceFieldName: 'gender', DestinationFieldName: 'Gender' },
512
+ { SourceFieldName: 'birth_date', DestinationFieldName: 'BirthDate' },
513
+ { SourceFieldName: 'language', DestinationFieldName: 'Language' },
514
+ { SourceFieldName: 'membership_number', DestinationFieldName: 'MembershipNumber' },
515
+ ];
516
+ }
517
+ GetOrganizationFieldMappings() {
518
+ return [
519
+ { SourceFieldName: 'uuid', DestinationFieldName: 'ExternalID', IsKeyField: true },
520
+ { SourceFieldName: 'legal_name', DestinationFieldName: 'Name' },
521
+ { SourceFieldName: 'alternate_name', DestinationFieldName: 'AlternateName' },
522
+ { SourceFieldName: 'description', DestinationFieldName: 'Description' },
523
+ { SourceFieldName: 'identifying_number', DestinationFieldName: 'IdentifyingNumber' },
524
+ { SourceFieldName: 'type', DestinationFieldName: 'OrganizationType' },
525
+ ];
526
+ }
527
+ GetPersonMembershipFieldMappings() {
528
+ return [
529
+ { SourceFieldName: 'uuid', DestinationFieldName: 'ExternalID', IsKeyField: true },
530
+ { SourceFieldName: 'starts_at', DestinationFieldName: 'StartDate' },
531
+ { SourceFieldName: 'ends_at', DestinationFieldName: 'EndDate' },
532
+ ];
533
+ }
534
+ GetConnectionFieldMappings() {
535
+ return [
536
+ { SourceFieldName: 'uuid', DestinationFieldName: 'ExternalID', IsKeyField: true },
537
+ { SourceFieldName: 'type', DestinationFieldName: 'ConnectionType' },
538
+ ];
539
+ }
540
+ // ─── Credential Management ───────────────────────────────────────
541
+ /**
542
+ * Reads Wicket credentials from the linked Credential entity,
543
+ * or falls back to CompanyIntegration Configuration JSON.
544
+ */
545
+ async LoadCredentials(companyIntegration, contextUser) {
546
+ const credentialID = companyIntegration.Get('CredentialID');
547
+ if (credentialID) {
548
+ const creds = await this.LoadFromCredentialEntity(credentialID, contextUser);
549
+ if (creds)
550
+ return creds;
551
+ }
552
+ const configJson = companyIntegration.Get('Configuration');
553
+ if (configJson) {
554
+ const creds = this.ParseCredentialJson(configJson);
555
+ if (creds)
556
+ return creds;
557
+ }
558
+ throw new Error('No Wicket credentials found. Attach a credential with apiSecret, adminUserUUID, ' +
559
+ 'and either apiUrl or tenantName. Or set Configuration JSON on the CompanyIntegration.');
560
+ }
561
+ /** Loads credentials from a Credential entity by ID. */
562
+ async LoadFromCredentialEntity(credentialID, contextUser) {
563
+ const md = new Metadata();
564
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
565
+ const loaded = await credential.Load(credentialID);
566
+ if (!loaded || !credential.Values)
567
+ return null;
568
+ return this.ParseCredentialJson(credential.Values);
569
+ }
570
+ /** Parses a JSON string to extract Wicket credentials. Returns null if required fields missing. */
571
+ ParseCredentialJson(json) {
572
+ try {
573
+ const parsed = JSON.parse(json);
574
+ const apiSecret = parsed['apiSecret'] ?? parsed['ApiSecret'] ?? parsed['api_secret'];
575
+ const adminUUID = parsed['adminUserUUID'] ?? parsed['AdminUserUUID'] ?? parsed['admin_user_uuid'];
576
+ const tenant = parsed['tenantName'] ?? parsed['TenantName'] ?? parsed['tenant_name'] ?? null;
577
+ const apiUrl = parsed['apiUrl'] ?? parsed['ApiUrl'] ?? parsed['api_url'] ?? null;
578
+ if (!apiSecret || !adminUUID)
579
+ return null;
580
+ // Need either a direct API URL or a tenant name to derive the URL
581
+ if (!apiUrl && !tenant)
582
+ return null;
583
+ return {
584
+ ApiSecret: apiSecret,
585
+ AdminUserUUID: adminUUID,
586
+ TenantName: tenant,
587
+ ApiUrl: apiUrl,
588
+ IssuerDomain: parsed['issuerDomain'] ?? parsed['IssuerDomain'] ?? null,
589
+ };
590
+ }
591
+ catch {
592
+ return null;
593
+ }
594
+ }
595
+ // ─── JSON:API Body Builders ──────────────────────────────────────
596
+ /**
597
+ * Builds a JSON:API-compliant request body for create/update operations.
598
+ *
599
+ * Output format:
600
+ * ```json
601
+ * {
602
+ * "data": {
603
+ * "type": "people",
604
+ * "attributes": { "given_name": "Jane", ... },
605
+ * "relationships": { ... }
606
+ * }
607
+ * }
608
+ * ```
609
+ */
610
+ BuildJsonApiBody(objectName, attributes, relationships, externalID) {
611
+ const jsonApiType = WICKET_JSONAPI_TYPES[objectName] ?? objectName;
612
+ const data = {
613
+ type: jsonApiType,
614
+ attributes: this.StripSystemFields(attributes),
615
+ };
616
+ if (externalID) {
617
+ data['id'] = externalID;
618
+ }
619
+ if (relationships && Object.keys(relationships).length > 0) {
620
+ data['relationships'] = relationships;
621
+ }
622
+ return { data };
623
+ }
624
+ /** Removes system-managed fields from attributes before sending to Wicket. */
625
+ StripSystemFields(attributes) {
626
+ const systemFields = new Set(['uuid', 'id', 'created_at', 'updated_at', 'slug']);
627
+ const cleaned = {};
628
+ for (const [key, value] of Object.entries(attributes)) {
629
+ if (!systemFields.has(key)) {
630
+ cleaned[key] = value;
631
+ }
632
+ }
633
+ return cleaned;
634
+ }
635
+ /** Builds the POST body for a Wicket /query search endpoint. */
636
+ BuildSearchQueryBody(options) {
637
+ const body = {
638
+ filter: options.Filters,
639
+ page: {
640
+ number: options.Page ?? 1,
641
+ size: options.PageSize ?? DEFAULT_PAGE_SIZE,
642
+ },
643
+ };
644
+ if (options.Sort) {
645
+ body['sort'] = options.Sort;
646
+ }
647
+ return body;
648
+ }
649
+ // ─── Response Parsers ────────────────────────────────────────────
650
+ /**
651
+ * Flattens a JSON:API record from the nested format:
652
+ * ```json
653
+ * { "id": "uuid", "type": "people", "attributes": { "given_name": "John", ... }, "relationships": {...} }
654
+ * ```
655
+ * into a flat object with all attributes at the top level plus `uuid` and `type`.
656
+ */
657
+ FlattenJsonApiRecord(record) {
658
+ const attributes = record['attributes'];
659
+ const result = {};
660
+ // Add flattened attributes first
661
+ if (attributes) {
662
+ for (const [key, value] of Object.entries(attributes)) {
663
+ result[key] = value;
664
+ }
665
+ }
666
+ // Add system fields
667
+ result['uuid'] = record['id'];
668
+ result['type'] = record['type'];
669
+ // Extract relationship IDs for easy access
670
+ this.FlattenRelationships(record, result);
671
+ return result;
672
+ }
673
+ /**
674
+ * Extracts relationship IDs from JSON:API relationships and adds them as flat fields.
675
+ * E.g., `relationships.person.data.id` becomes `person_id` on the flat record.
676
+ */
677
+ FlattenRelationships(record, target) {
678
+ const relationships = record['relationships'];
679
+ if (!relationships)
680
+ return;
681
+ for (const [relName, relData] of Object.entries(relationships)) {
682
+ const rel = relData;
683
+ if (!rel?.['data'])
684
+ continue;
685
+ const data = rel['data'];
686
+ if (Array.isArray(data)) {
687
+ // Has-many: store as array of IDs
688
+ target[`${relName}_ids`] = data.map(d => d['id']);
689
+ }
690
+ else {
691
+ // Belongs-to: store as single ID
692
+ const belongsTo = data;
693
+ target[`${relName}_id`] = belongsTo['id'];
694
+ }
695
+ }
696
+ }
697
+ /** Parses a CRUD operation response into a WicketCRUDResult. */
698
+ ParseCRUDResponse(response, operation) {
699
+ const success = response.Status >= 200 && response.Status < 300;
700
+ if (!success) {
701
+ const bodyPreview = this.FormatBodyPreview(response.Body);
702
+ return {
703
+ Success: false,
704
+ ErrorMessage: `Wicket ${operation} failed with status ${response.Status}: ${bodyPreview}`,
705
+ StatusCode: response.Status,
706
+ };
707
+ }
708
+ // Extract the created/updated record ID from the response
709
+ const body = response.Body;
710
+ const data = body['data'];
711
+ const externalID = data?.['id'];
712
+ return {
713
+ Success: true,
714
+ ExternalID: externalID,
715
+ StatusCode: response.Status,
716
+ };
717
+ }
718
+ /** Parses a search response into a WicketSearchResult. */
719
+ ParseSearchResponse(response, objectName) {
720
+ if (response.Status < 200 || response.Status >= 300) {
721
+ const bodyPreview = this.FormatBodyPreview(response.Body);
722
+ throw new Error(`Wicket search failed with status ${response.Status}: ${bodyPreview}`);
723
+ }
724
+ const body = response.Body;
725
+ const records = this.NormalizeResponse(body, 'data');
726
+ const meta = body['meta'];
727
+ const pageInfo = meta?.['page'];
728
+ const totalCount = pageInfo?.total_count ?? records.length;
729
+ const currentPage = pageInfo?.number ?? 1;
730
+ const pageSize = pageInfo?.size ?? DEFAULT_PAGE_SIZE;
731
+ const totalPages = Math.ceil(totalCount / pageSize);
732
+ return {
733
+ Records: records.map(r => ({
734
+ ExternalID: String(r['uuid'] ?? ''),
735
+ ObjectType: objectName,
736
+ Fields: r,
737
+ })),
738
+ TotalCount: totalCount,
739
+ HasMore: currentPage < totalPages,
740
+ };
741
+ }
742
+ // ─── Validation ──────────────────────────────────────────────────
743
+ /** Throws if the object does not support write operations. */
744
+ ValidateWriteAllowed(objectName) {
745
+ if (IMMUTABLE_OBJECTS.has(objectName)) {
746
+ throw new Error(`Write operations are not allowed on "${objectName}". ` +
747
+ `Touchpoints are immutable audit trail records.`);
748
+ }
749
+ }
750
+ /** Throws if the object does not support delete operations. */
751
+ ValidateDeleteAllowed(objectName) {
752
+ // Touchpoints and resource_tags typically don't support delete
753
+ const nonDeletable = new Set(['touchpoints', 'resource_tags']);
754
+ if (nonDeletable.has(objectName)) {
755
+ throw new Error(`Delete operations are not supported for "${objectName}".`);
756
+ }
757
+ }
758
+ // ─── URL Helpers ─────────────────────────────────────────────────
759
+ /**
760
+ * Resolves the API path for a given Wicket object name.
761
+ * Handles top-level and nested (contact info) objects.
762
+ */
763
+ ResolveObjectAPIPath(objectName) {
764
+ // Contact info objects nested under people
765
+ if (objectName === 'people_emails')
766
+ return '/people';
767
+ if (objectName === 'people_phones')
768
+ return '/people';
769
+ if (objectName === 'people_addresses')
770
+ return '/people';
771
+ if (objectName === 'org_emails')
772
+ return '/organizations';
773
+ if (objectName === 'org_phones')
774
+ return '/organizations';
775
+ if (objectName === 'org_addresses')
776
+ return '/organizations';
777
+ // For direct CRUD, emails/phones/addresses use their direct endpoints
778
+ if (objectName === 'emails' || objectName === 'phones' || objectName === 'addresses') {
779
+ return `/${objectName}`;
780
+ }
781
+ // Top-level objects
782
+ return `/${objectName}`;
783
+ }
784
+ // ─── HTTP Helpers ────────────────────────────────────────────────
785
+ /** Ensures minimum interval between API requests to avoid rate limiting. */
786
+ async ThrottleRequest() {
787
+ const elapsed = Date.now() - this.lastRequestTime;
788
+ if (elapsed < MIN_REQUEST_INTERVAL_MS) {
789
+ await this.Sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
790
+ }
791
+ }
792
+ /** Executes an HTTP request with a timeout and optional body. */
793
+ async FetchWithTimeout(url, method, headers, body) {
794
+ const controller = new AbortController();
795
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
796
+ const fetchOptions = {
797
+ method,
798
+ headers,
799
+ signal: controller.signal,
800
+ };
801
+ if (body && (method === 'POST' || method === 'PATCH' || method === 'PUT')) {
802
+ fetchOptions.body = JSON.stringify(body);
803
+ }
804
+ try {
805
+ return await fetch(url, fetchOptions);
806
+ }
807
+ catch (err) {
808
+ if (err instanceof Error && err.name === 'AbortError') {
809
+ throw new Error(`Wicket API request timed out after ${REQUEST_TIMEOUT_MS / 1000}s: ${url}`);
810
+ }
811
+ throw err;
812
+ }
813
+ finally {
814
+ clearTimeout(timeoutId);
815
+ }
816
+ }
817
+ /** Safely parses a response body as JSON, falling back to text. */
818
+ async ParseResponseBody(response) {
819
+ const contentType = response.headers.get('content-type') ?? '';
820
+ if (contentType.includes('json')) {
821
+ return response.json();
822
+ }
823
+ return response.text();
824
+ }
825
+ /** Calculates retry delay from Retry-After header or exponential backoff. */
826
+ CalculateRetryDelay(response, attempt) {
827
+ const retryAfter = response.headers.get('retry-after');
828
+ if (retryAfter) {
829
+ const parsed = parseInt(retryAfter, 10);
830
+ if (!isNaN(parsed) && parsed > 0)
831
+ return parsed * 1000;
832
+ }
833
+ return Math.min(1000 * Math.pow(2, attempt), 30000);
834
+ }
835
+ /** Converts a fetch Response + parsed body into a RESTResponse. */
836
+ BuildRESTResponse(response, body) {
837
+ const headers = {};
838
+ response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
839
+ return { Status: response.status, Body: body, Headers: headers };
840
+ }
841
+ /** Formats a response body as a truncated string for error messages. */
842
+ FormatBodyPreview(body) {
843
+ if (typeof body === 'string')
844
+ return body.slice(0, 500);
845
+ return JSON.stringify(body).slice(0, 500);
846
+ }
847
+ /** Builds a WicketCRUDResult from a caught error. */
848
+ BuildCRUDError(err) {
849
+ const message = err instanceof Error ? err.message : String(err);
850
+ return { Success: false, ErrorMessage: message, StatusCode: 0 };
851
+ }
852
+ /** Returns a promise that resolves after the specified number of milliseconds. */
853
+ Sleep(ms) {
854
+ return new Promise(resolve => setTimeout(resolve, ms));
855
+ }
856
+ };
857
+ WicketConnector = __decorate([
858
+ RegisterClass(BaseIntegrationConnector, 'WicketConnector')
859
+ ], WicketConnector);
860
+ export { WicketConnector };
861
+ //# sourceMappingURL=WicketConnector.js.map