@memberjunction/actions-apollo 2.78.0 → 2.80.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/accounts.js CHANGED
@@ -32,69 +32,234 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
32
32
  return (mod && mod.__esModule) ? mod : { "default": mod };
33
33
  };
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
- exports.LoadApolloAccountsEnrichmentAction = exports.ApolloAccountsEnrichmentAction = void 0;
35
+ exports.LoadApolloAccountsEnrichmentAction = exports.ApolloEnrichmentAccountsAction = void 0;
36
36
  const actions_1 = require("@memberjunction/actions");
37
37
  const global_1 = require("@memberjunction/global");
38
38
  const Config = __importStar(require("./config"));
39
39
  const core_1 = require("@memberjunction/core");
40
40
  const axios_1 = __importDefault(require("axios"));
41
- // ApolloEnrichContact this Action would use the www.apollo.io enrichment service and enrich a "contact" type of record. The parameters to this Action would be:
42
- // EntityName - entity in question that contacts "contact" types of records
43
- // EmailField - string - field name in the target entity that contains the email to be used for lookups
44
- // FirstNameField - string
45
- // LastNameField - string
46
- // AccountNameField - string
47
- // EnrichmentFieldMapping - JSON - this JSON string would contain mappings for the wide array of enrichment outputs. The idea is that the outputs could include a wide array of things and this would map any of the scalar values that Apollo.io provides back to entity field names in the target entity
48
- // EducationHistoryEntityName - string - optional - this would contain the name of the entity that will be used to stored the retrieved education history for each contact
49
- // EmploymentHistoryEntityName - string - optional - would contain the name of the entity that will be used to store the retrieved employment history for each contact
50
- // ApolloEnrichAccount - same idea and pattern as above, but would be for Account style entities
51
- // Would appreciate feedback on (a) the approach to how we package this stuff and (b) the approach to params noted above.
52
- let ApolloAccountsEnrichmentAction = class ApolloAccountsEnrichmentAction extends actions_1.BaseAction {
41
+ /**
42
+ * Action that enriches account records using Apollo.io's organization and people data
43
+ *
44
+ * This action performs comprehensive account enrichment by:
45
+ * 1. Enriching organization data (address, phone, social profiles, etc.)
46
+ * 2. Creating technology stack records for the organization
47
+ * 3. Finding and enriching contacts associated with the organization
48
+ * 4. Creating employment/education history for contacts
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * // Basic account enrichment
53
+ * await runAction({
54
+ * ActionName: 'ApolloEnrichmentAccountsAction',
55
+ * Params: [{
56
+ * Name: 'AccountEntityFieldMappings',
57
+ * Value: JSON.stringify({
58
+ * EntityName: 'Accounts',
59
+ * Filter: 'Domain IS NOT NULL AND EnrichedAt IS NULL',
60
+ * DomainField: 'Domain',
61
+ * AccountIDField: 'ID',
62
+ * EnrichedAtField: 'EnrichedAt'
63
+ * })
64
+ * }]
65
+ * });
66
+ *
67
+ * // Full enrichment with contacts and technologies
68
+ * await runAction({
69
+ * ActionName: 'ApolloEnrichmentAccountsAction',
70
+ * Params: [{
71
+ * Name: 'AccountEntityFieldMappings',
72
+ * Value: JSON.stringify(accountConfig)
73
+ * }, {
74
+ * Name: 'ContactEntityFieldMappings',
75
+ * Value: JSON.stringify(contactConfig)
76
+ * }, {
77
+ * Name: 'AccountTechnologyEntityFieldMappings',
78
+ * Value: JSON.stringify(technologyConfig)
79
+ * }]
80
+ * });
81
+ * ```
82
+ */
83
+ let ApolloEnrichmentAccountsAction = class ApolloEnrichmentAccountsAction extends actions_1.BaseAction {
53
84
  constructor() {
54
85
  super(...arguments);
86
+ /**
87
+ * Job titles to exclude when creating contact records
88
+ * These are typically not decision-makers or relevant contacts
89
+ */
55
90
  this.ExcludeTitles = ['member', 'student member', 'student', 'volunteer'];
56
91
  }
92
+ /**
93
+ * Main entry point for the Apollo account enrichment action
94
+ *
95
+ * @param params - The action parameters containing:
96
+ * - AccountEntityFieldMappings: Required JSON configuration for account entity field mappings
97
+ * - ContactEntityFieldMappings: Optional JSON configuration for contact entity field mappings
98
+ * - AccountTechnologyEntityFieldMappings: Optional JSON configuration for technology tracking
99
+ * - TechnologyCategoryEntityFieldMappings: Optional JSON configuration for technology categories
100
+ * - ContactEducationHistoryEntityFieldMappings: Optional JSON configuration for education history
101
+ *
102
+ * @returns Promise<ActionResultSimple> indicating success/failure and any error messages
103
+ */
57
104
  async InternalRunAction(params) {
105
+ try {
106
+ // Validate API configuration
107
+ const configValidation = this.validateConfiguration();
108
+ if (!configValidation.success) {
109
+ return configValidation.error;
110
+ }
111
+ // Extract and validate parameters
112
+ const paramValidation = this.extractAndValidateParameters(params);
113
+ if (!paramValidation.success) {
114
+ return paramValidation.error;
115
+ }
116
+ const actionParams = paramValidation.data;
117
+ // Process all account records
118
+ return await this.processAllAccountRecords(actionParams, params.ContextUser, 1);
119
+ }
120
+ catch (error) {
121
+ (0, core_1.LogError)('Unexpected error in ApolloAccountsEnrichmentAction', undefined, error);
122
+ return {
123
+ Success: false,
124
+ Message: `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`,
125
+ ResultCode: "FAILED"
126
+ };
127
+ }
128
+ }
129
+ /**
130
+ * Validates that the Apollo.io API key is configured properly
131
+ * @returns Validation result with success flag and potential error details
132
+ */
133
+ validateConfiguration() {
58
134
  if (!Config.ApolloAPIKey) {
59
- throw new Error('Apollo.io API key not found');
135
+ return {
136
+ success: false,
137
+ error: {
138
+ Success: false,
139
+ Message: 'Apollo.io API key not found in configuration',
140
+ ResultCode: "CONFIGURATION_ERROR"
141
+ }
142
+ };
60
143
  }
61
- const accountEntityFieldNamesParam = params.Params.find(p => p.Name === 'AccountEntityFieldNameJSON');
62
- const accountTechnologyEntityFieldNamesParam = params.Params.find(p => p.Name === 'AccountTechnologyEntityFieldNameJSON');
63
- const technologyCategoryEntityFieldNamesParam = params.Params.find(p => p.Name === 'TechnologyCategoryEntityFieldNameJSON');
64
- const ContactEntityFieldNamesParam = params.Params.find(p => p.Name === 'ContactEntityFieldNameJSON');
65
- const ContactEducationHistoryEntityFieldNamesParam = params.Params.find(p => p.Name === 'ContactEducationHistoryEntityFieldNameJSON');
66
- let accountEntityFieldNames = null;
67
- let accountTechnologyEntityFieldNames = null;
68
- let technologyCategoryEntityFieldNames = null;
69
- let ContactEntityFieldNames = null;
70
- let ContactEducationHistoryEntityFieldNames = null;
71
- if (!accountEntityFieldNamesParam) {
72
- throw new Error('AccountEntityFieldNames parameter not found');
144
+ return { success: true };
145
+ }
146
+ /**
147
+ * Extracts and validates all action parameters from the request
148
+ *
149
+ * @param params - The action parameters containing JSON configurations
150
+ * @returns Object with success flag, validated parameters, or error details
151
+ */
152
+ extractAndValidateParameters(params) {
153
+ const accountEntityFieldsParam = this.getRequiredParam(params, 'AccountEntityFieldMappings');
154
+ if (!accountEntityFieldsParam) {
155
+ return {
156
+ success: false,
157
+ error: {
158
+ Success: false,
159
+ Message: 'Missing required parameter: AccountEntityFieldMappings',
160
+ ResultCode: "VALIDATION_ERROR"
161
+ }
162
+ };
73
163
  }
74
- if (accountEntityFieldNamesParam && accountEntityFieldNamesParam.Value) {
75
- accountEntityFieldNames = JSON.parse(accountEntityFieldNamesParam.Value);
76
- if (accountEntityFieldNames) {
77
- throw new Error('accountEntityFieldNames is empty');
164
+ let accountEntityFields;
165
+ try {
166
+ accountEntityFields = this.parseJSONParam(accountEntityFieldsParam.Value);
167
+ if (!accountEntityFields) {
168
+ throw new Error('Parsed AccountEntityFieldMappings is empty');
78
169
  }
79
170
  }
80
- if (accountTechnologyEntityFieldNamesParam && accountTechnologyEntityFieldNamesParam.Value) {
81
- accountTechnologyEntityFieldNames = JSON.parse(accountTechnologyEntityFieldNamesParam.Value) || null;
171
+ catch (error) {
172
+ return {
173
+ success: false,
174
+ error: {
175
+ Success: false,
176
+ Message: 'Invalid AccountEntityFieldMappings parameter: must be valid JSON or object',
177
+ ResultCode: "VALIDATION_ERROR"
178
+ }
179
+ };
82
180
  }
83
- if (technologyCategoryEntityFieldNamesParam && technologyCategoryEntityFieldNamesParam.Value) {
84
- technologyCategoryEntityFieldNames = JSON.parse(technologyCategoryEntityFieldNamesParam.Value) || null;
181
+ // Extract optional parameters
182
+ const accountTechnologyEntityFields = this.parseOptionalJSONParam(params, 'AccountTechnologyEntityFieldMappings');
183
+ const technologyCategoryEntityFields = this.parseOptionalJSONParam(params, 'TechnologyCategoryEntityFieldMappings');
184
+ const contactEntityFields = this.parseOptionalJSONParam(params, 'ContactEntityFieldMappings');
185
+ const contactEducationHistoryEntityFields = this.parseOptionalJSONParam(params, 'ContactEducationHistoryEntityFieldMappings');
186
+ return {
187
+ success: true,
188
+ data: {
189
+ accountEntityFields,
190
+ accountTechnologyEntityFields,
191
+ technologyCategoryEntityFields,
192
+ contactEntityFields,
193
+ contactEducationHistoryEntityFields
194
+ }
195
+ };
196
+ }
197
+ /**
198
+ * Gets a required parameter from the action parameters
199
+ * @param params - The action parameters
200
+ * @param paramName - Name of the required parameter
201
+ * @returns The parameter if found, null otherwise
202
+ */
203
+ getRequiredParam(params, paramName) {
204
+ const param = params.Params.find(p => p.Name === paramName);
205
+ return param || null;
206
+ }
207
+ /**
208
+ * Parses an optional JSON parameter with error handling
209
+ * @param params - The action parameters
210
+ * @param paramName - Name of the optional JSON parameter
211
+ * @returns Parsed object of type T or null if not provided/invalid
212
+ */
213
+ parseOptionalJSONParam(params, paramName) {
214
+ const param = params.Params.find(p => p.Name === paramName);
215
+ if (!param || !param.Value) {
216
+ return null;
85
217
  }
86
- if (ContactEntityFieldNamesParam && ContactEntityFieldNamesParam.Value) {
87
- ContactEntityFieldNames = JSON.parse(ContactEntityFieldNamesParam.Value) || null;
218
+ try {
219
+ return this.parseJSONParam(param.Value) || null;
88
220
  }
89
- if (ContactEducationHistoryEntityFieldNamesParam && ContactEducationHistoryEntityFieldNamesParam.Value) {
90
- ContactEducationHistoryEntityFieldNames = JSON.parse(ContactEducationHistoryEntityFieldNamesParam.Value) || null;
221
+ catch (error) {
222
+ (0, core_1.LogError)(`Error parsing ${paramName}`, undefined, error);
223
+ return null;
91
224
  }
225
+ }
226
+ /**
227
+ * Parses a parameter value that may be a JSON string or already parsed object
228
+ * Handles the case where action parameters ending with JSON can be either strings or objects
229
+ *
230
+ * @param value - The parameter value to parse (string or object)
231
+ * @returns Parsed object of type T
232
+ * @throws Error if value cannot be parsed as JSON (when it's a string)
233
+ */
234
+ parseJSONParam(value) {
235
+ // If value is already an object (not a string), return it directly
236
+ if (typeof value === 'object' && value !== null) {
237
+ return value;
238
+ }
239
+ // If value is a string, try to parse it as JSON
240
+ if (typeof value === 'string') {
241
+ return JSON.parse(value);
242
+ }
243
+ // For other types, try to convert to string first then parse
244
+ return JSON.parse(String(value));
245
+ }
246
+ /**
247
+ * Recursively processes all account records that match the filter criteria
248
+ * Uses batching and concurrency control to handle large datasets efficiently
249
+ *
250
+ * @param actionParams - Validated action parameters with entity field mappings
251
+ * @param contextUser - User context for data access and security
252
+ * @param executionCount - Current execution count to prevent infinite recursion (max 5)
253
+ * @returns Promise indicating overall success/failure of the batch
254
+ */
255
+ async processAllAccountRecords(actionParams, contextUser, executionCount = 1) {
92
256
  const md = new core_1.Metadata();
93
257
  const rv = new core_1.RunView();
258
+ // Initial query to get accounts to process
94
259
  const runViewResult = await rv.RunView({
95
- EntityName: accountEntityFieldNames.EntityName,
96
- ExtraFilter: accountEntityFieldNames.ExtraFilter,
97
- }, params.ContextUser);
260
+ EntityName: actionParams.accountEntityFields.EntityName,
261
+ ExtraFilter: actionParams.accountEntityFields.Filter,
262
+ }, contextUser);
98
263
  if (!runViewResult.Success) {
99
264
  return {
100
265
  Success: false,
@@ -103,226 +268,339 @@ let ApolloAccountsEnrichmentAction = class ApolloAccountsEnrichmentAction extend
103
268
  };
104
269
  }
105
270
  const results = runViewResult.Results;
106
- const resultLength = runViewResult.Results.length;
107
- const tasks = []; // to hold promises
271
+ const resultLength = results.length;
272
+ if (resultLength === 0) {
273
+ (0, core_1.LogStatus)('+++ Completed domain enrichment, no records left to process.');
274
+ return {
275
+ Success: true,
276
+ Message: "ApolloAccountsEnrichment executed successfully.",
277
+ ResultCode: "SUCCESS"
278
+ };
279
+ }
280
+ // Check execution limit to prevent infinite recursion
281
+ if (executionCount >= 5) {
282
+ (0, core_1.LogStatus)(`+++ Reached maximum execution limit (${executionCount}), stopping to prevent infinite recursion.`);
283
+ return {
284
+ Success: true,
285
+ Message: `ApolloAccountsEnrichment stopped after ${executionCount} executions to prevent infinite recursion. ${resultLength} records may remain unprocessed.`,
286
+ ResultCode: "SUCCESS"
287
+ };
288
+ }
289
+ // Process accounts in concurrent groups
290
+ await this.processAccountGroups(results, actionParams, md, contextUser);
291
+ // Check if there are more records to process by running the query again
292
+ (0, core_1.LogStatus)(`--- Checking for remaining records... (execution ${executionCount + 1}/5)`);
293
+ return await this.processAllAccountRecords(actionParams, contextUser, executionCount + 1);
294
+ }
295
+ /**
296
+ * Processes account records in concurrent groups to optimize performance
297
+ * Respects the configured concurrency limit to avoid overwhelming APIs
298
+ *
299
+ * @param results - Array of account records to process
300
+ * @param actionParams - Configuration parameters for enrichment
301
+ * @param md - Metadata instance for entity operations
302
+ * @param contextUser - User context for data access
303
+ */
304
+ async processAccountGroups(results, actionParams, md, contextUser) {
305
+ const tasks = [];
306
+ const resultLength = results.length;
108
307
  for (const [index, record] of results.entries()) {
109
308
  const processParams = {
110
309
  Record: record,
111
- AccountEntity: accountEntityFieldNames,
112
- AccountTechnologyEntity: accountTechnologyEntityFieldNames,
113
- TechnologyCategoryEntity: technologyCategoryEntityFieldNames,
114
- ContactEntity: ContactEntityFieldNames,
115
- ContactEducationHistoryEntity: ContactEducationHistoryEntityFieldNames
310
+ AccountEntity: actionParams.accountEntityFields,
311
+ AccountTechnologyEntity: actionParams.accountTechnologyEntityFields || null,
312
+ TechnologyCategoryEntity: actionParams.technologyCategoryEntityFields || null,
313
+ ContactEntity: actionParams.contactEntityFields || null,
314
+ ContactEducationHistoryEntity: actionParams.contactEducationHistoryEntityFields || null
116
315
  };
117
- const task = this.ProcessSingleDomain(processParams, index, resultLength, md, params.ContextUser);
316
+ const task = this.ProcessSingleDomain(processParams, index, resultLength, md, contextUser);
118
317
  tasks.push(task);
119
318
  if (tasks.length === Config.ConcurrentGroups || index + 1 >= resultLength) {
120
- // if we started n tasks or reached the end of the result array, await all started tasks
121
319
  await Promise.all(tasks);
122
- // clear tasks
123
320
  tasks.length = 0;
124
321
  }
125
322
  }
126
- if (resultLength > 0) {
127
- // restart the process once more to see if any records remaining
128
- (0, core_1.LogStatus)('--- Records left, RESTARTING PROCESS...');
129
- return await this.InternalRunAction(params);
130
- }
131
- else {
132
- (0, core_1.LogStatus)('+++ Completed domain enrichment, no records left to process.');
133
- return {
134
- Success: true,
135
- Message: "ApolloAccountsEnrichment executed successfully.",
136
- ResultCode: "SUCCESS"
137
- };
138
- }
139
323
  }
324
+ /**
325
+ * Processes a single domain for both organization and people enrichment
326
+ * This is the main orchestration method for enriching one account
327
+ *
328
+ * @param params - Domain processing parameters with entity configurations
329
+ * @param startRow - Current row number for progress tracking
330
+ * @param totalRows - Total number of rows for progress tracking
331
+ * @param md - Metadata instance for entity operations
332
+ * @param currentUser - User context for data access
333
+ * @returns Promise<boolean> indicating success/failure of domain processing
334
+ */
140
335
  async ProcessSingleDomain(params, startRow, totalRows, md, currentUser) {
141
336
  const record = params.Record;
142
- const accountID = record[params.AccountEntity.AccountIDName];
143
- const domain = record[params.AccountEntity.DomainParamName];
144
- const enrichOrganizationResult = await this.EnrichOrganization(params, startRow, totalRows, md, currentUser);
145
- const createAndEnrichPeopleResult = await this.CreateAndEnrichPeople(params, accountID, domain, startRow, md, currentUser);
337
+ const accountID = record[params.AccountEntity.AccountIDField];
338
+ const domain = record[params.AccountEntity.DomainField];
339
+ const enrichOrganizationResult = await this.enrichOrganization(params, startRow, totalRows, md, currentUser);
340
+ const createAndEnrichPeopleResult = await this.createAndEnrichPeople(params, accountID, domain, startRow, md, currentUser);
146
341
  return enrichOrganizationResult && createAndEnrichPeopleResult;
147
342
  }
148
- async EnrichOrganization(params, startRow, totalRows, md, currentUser) {
149
- const record = params.Record;
150
- const orgQueryString = {
151
- api_key: Config.ApolloAPIKey,
152
- domain: record[params.AccountEntity.DomainParamName]
153
- };
154
- const headers = {
155
- 'Cache-Control': 'no-cache',
156
- 'Content-Type': 'application/json'
157
- };
158
- // get the organization enrichment data first
159
- const orgResponse = await this.WrapApolloCall('get', 'organizations/enrich', null, { params: orgQueryString, headers: headers });
160
- if (!orgResponse || orgResponse.status !== 200) {
161
- return false;
162
- }
163
- // here we want to iterate through all the results and update the contacts with the enriched data if
164
- // the API returned a match
165
- const result = orgResponse.data;
166
- let bSuccess = true;
167
- if (result && result.organization) {
168
- const organization = result.organization;
169
- // use the entity object to load up the account and then update some of the fields in it
170
- const accountEntity = await md.GetEntityObject(params.AccountEntity.EntityName, currentUser);
171
- accountEntity.SetMany(record);
172
- if (params.AccountEntity.AddressFieldName) {
173
- accountEntity.Set(params.AccountEntity.AddressFieldName, organization.street_address);
174
- }
175
- if (params.AccountEntity.CityFieldNameName) {
176
- accountEntity.Set(params.AccountEntity.CityFieldNameName, organization.city);
177
- }
178
- if (params.AccountEntity.CityFieldNameName) {
179
- accountEntity.Set(params.AccountEntity.CityFieldNameName, organization.city);
180
- }
181
- if (params.AccountEntity.StateProvinceFieldName) {
182
- //accountEntity.Set(params.StateProvinceFieldName, organization.state);
183
- }
184
- if (params.AccountEntity.PostalCodeFieldName) {
185
- accountEntity.Set(params.AccountEntity.PostalCodeFieldName, organization.postal_code);
186
- }
187
- if (params.AccountEntity.DescriptionFieldName) {
188
- accountEntity.Set(params.AccountEntity.DescriptionFieldName, organization.short_description);
189
- }
190
- if (params.AccountEntity.PhoneNumberFieldName) {
191
- accountEntity.Set(params.AccountEntity.PhoneNumberFieldName, organization.phone);
192
- }
193
- if (params.AccountEntity.CountryFieldName) {
194
- accountEntity.Set(params.AccountEntity.CountryFieldName, organization.country);
195
- }
196
- if (params.AccountEntity.LinkedInFieldName) {
197
- accountEntity.Set(params.AccountEntity.LinkedInFieldName, organization.linkedin_url);
198
- }
199
- if (params.AccountEntity.LogoURLFieldName) {
200
- accountEntity.Set(params.AccountEntity.LogoURLFieldName, organization.logo_url);
201
- }
202
- if (params.AccountEntity.FacebookFieldName) {
203
- accountEntity.Set(params.AccountEntity.FacebookFieldName, organization.facebook_url);
343
+ /**
344
+ * Enriches organization data using Apollo.io's organization enrichment endpoint
345
+ * Updates account record with company information and triggers technology processing
346
+ *
347
+ * @param params - Domain processing parameters
348
+ * @param startRow - Current row for progress tracking
349
+ * @param totalRows - Total rows for progress tracking
350
+ * @param md - Metadata instance for entity operations
351
+ * @param currentUser - User context for data access
352
+ * @returns Promise<boolean> indicating success/failure
353
+ */
354
+ async enrichOrganization(params, startRow, totalRows, md, currentUser) {
355
+ try {
356
+ const record = params.Record;
357
+ const orgQueryString = {
358
+ api_key: Config.ApolloAPIKey,
359
+ domain: record[params.AccountEntity.DomainField]
360
+ };
361
+ const headers = {
362
+ 'Cache-Control': 'no-cache',
363
+ 'Content-Type': 'application/json'
364
+ };
365
+ // Get the organization enrichment data
366
+ const orgResponse = await this.WrapApolloCall('get', 'organizations/enrich', null, { params: orgQueryString, headers: headers });
367
+ if (!orgResponse || orgResponse.status !== 200) {
368
+ return false;
204
369
  }
205
- if (params.AccountEntity.TwitterFieldName) {
206
- accountEntity.Set(params.AccountEntity.TwitterFieldName, organization.twitter_url);
370
+ const result = orgResponse.data;
371
+ let bSuccess = true;
372
+ if (result && result.organization) {
373
+ bSuccess = await this.updateAccountWithOrganizationData(result.organization, params, record, md, currentUser);
374
+ if (params.AccountTechnologyEntity && bSuccess) {
375
+ const accountID = record[params.AccountEntity.AccountIDField];
376
+ bSuccess = bSuccess && await this.CreateAccountTechnologyRecords(params, accountID, result.organization.current_technologies, md, currentUser);
377
+ }
207
378
  }
208
- if (params.AccountEntity.EnrichedAtField) {
209
- accountEntity.Set(params.AccountEntity.EnrichedAtField, new Date());
379
+ else {
380
+ // No match found, but still update enriched timestamp
381
+ (0, core_1.LogStatus)(` > No matches found for this domain: ${params.Record[params.AccountEntity.DomainField]}`);
382
+ bSuccess = await this.updateAccountEnrichedTimestamp(params, record, md, currentUser);
210
383
  }
211
- // no field for twitter, add this mapping when that's done
212
- //accountEntity.Twitter = result.organization.twitter_url;
384
+ return bSuccess;
385
+ }
386
+ catch (error) {
387
+ (0, core_1.LogError)('Error enriching organization', undefined, error);
388
+ return false;
389
+ }
390
+ }
391
+ /**
392
+ * Updates an account entity with enriched organization data from Apollo.io
393
+ * Maps Apollo organization fields to the configured account entity fields
394
+ *
395
+ * @param organization - Apollo organization data
396
+ * @param params - Processing parameters with field mappings
397
+ * @param record - Current account record data
398
+ * @param md - Metadata instance for entity operations
399
+ * @param currentUser - User context for data access
400
+ * @returns Promise<boolean> indicating save success/failure
401
+ */
402
+ async updateAccountWithOrganizationData(organization, params, record, md, currentUser) {
403
+ try {
404
+ const accountEntity = await md.GetEntityObject(params.AccountEntity.EntityName, core_1.CompositeKey.FromID(record.ID), currentUser);
405
+ // Set organization data fields
406
+ this.setFieldIfExists(accountEntity, params.AccountEntity.AddressField, organization.street_address);
407
+ this.setFieldIfExists(accountEntity, params.AccountEntity.CityField, organization.city);
408
+ this.setFieldIfExists(accountEntity, params.AccountEntity.StateProvinceField, organization.state);
409
+ this.setFieldIfExists(accountEntity, params.AccountEntity.PostalCodeField, organization.postal_code);
410
+ this.setFieldIfExists(accountEntity, params.AccountEntity.DescriptionField, organization.short_description);
411
+ this.setFieldIfExists(accountEntity, params.AccountEntity.PhoneNumberField, organization.phone);
412
+ this.setFieldIfExists(accountEntity, params.AccountEntity.CountryField, organization.country);
413
+ this.setFieldIfExists(accountEntity, params.AccountEntity.LinkedInField, organization.linkedin_url);
414
+ this.setFieldIfExists(accountEntity, params.AccountEntity.LogoURLField, organization.logo_url);
415
+ this.setFieldIfExists(accountEntity, params.AccountEntity.FacebookField, organization.facebook_url);
416
+ this.setFieldIfExists(accountEntity, params.AccountEntity.TwitterField, organization.twitter_url);
417
+ this.setFieldIfExists(accountEntity, params.AccountEntity.EnrichedAtField, new Date());
213
418
  const saveResult = await accountEntity.Save();
214
419
  if (!saveResult) {
215
420
  (0, core_1.LogError)('Error updating account record with enriched data', undefined, accountEntity.LatestResult);
216
421
  }
217
- if (params.AccountTechnologyEntity) {
218
- // now, attempt to create technology records for linking to the account
219
- const accountID = record[params.AccountEntity.AccountIDName];
220
- bSuccess = bSuccess && await this.CreateAccountTechnologyRecords(params, accountID, result.organization.current_technologies, md, currentUser);
221
- }
222
- return bSuccess;
422
+ return saveResult;
423
+ }
424
+ catch (error) {
425
+ (0, core_1.LogError)('Error updating account with organization data', undefined, error);
426
+ return false;
223
427
  }
224
- else {
225
- // missing records == details.length means no matches were found
226
- (0, core_1.LogStatus)(` > No matches found for this domain: ${params.Record[params.AccountEntity.DomainParamName]}`);
227
- // even though there was no match, we still update the LastEnrichedAt field on the account otherwise we'll keep on attempting to run this each
228
- // time this process goes through the list of accounts.
229
- const accountEntity = await md.GetEntityObject('Accounts', currentUser);
230
- accountEntity.SetMany(params.Record);
428
+ }
429
+ /**
430
+ * Updates the enrichment timestamp on an account even when no data was found
431
+ * Prevents repeated attempts to enrich accounts with no Apollo data
432
+ *
433
+ * @param params - Processing parameters with field mappings
434
+ * @param record - Current account record data
435
+ * @param md - Metadata instance for entity operations
436
+ * @param currentUser - User context for data access
437
+ * @returns Promise<boolean> indicating save success/failure
438
+ */
439
+ async updateAccountEnrichedTimestamp(params, record, md, currentUser) {
440
+ try {
441
+ const accountEntity = await md.GetEntityObject('Accounts', core_1.CompositeKey.FromID(record.ID), currentUser);
231
442
  accountEntity.Set(params.AccountEntity.EnrichedAtField, new Date());
232
443
  const saveResult = await accountEntity.Save();
233
444
  if (!saveResult) {
234
- (0, core_1.LogError)('Error updating account record with enriched data', undefined, accountEntity.LatestResult);
445
+ (0, core_1.LogError)('Error updating account record with enriched timestamp', undefined, accountEntity.LatestResult);
235
446
  }
236
447
  return saveResult;
237
448
  }
238
- }
239
- async CreateAccountTechnologyRecords(params, accountID, technologies, md, currentUser) {
240
- let bSuccess = true;
241
- const ATEntity = params.AccountTechnologyEntity;
242
- if (!ATEntity) {
449
+ catch (error) {
450
+ (0, core_1.LogError)('Error updating account enriched timestamp', undefined, error);
243
451
  return false;
244
452
  }
245
- const rv = new core_1.RunView();
246
- const runViewResult = await rv.RunView({
247
- EntityName: `${ATEntity.EntityName}`,
248
- ExtraFilter: `${ATEntity.AccountIDFieldName} = ${accountID}`
249
- }, currentUser);
250
- if (!runViewResult.Success) {
251
- (0, core_1.LogError)('Error querying for existing Account Technologies', undefined, runViewResult.ErrorMessage);
252
- return false;
453
+ }
454
+ /**
455
+ * Safely sets a field on an entity if the field name is defined and value is not null
456
+ * Prevents errors from undefined field mappings
457
+ *
458
+ * @param entity - Target entity to update
459
+ * @param fieldName - Name of the field (can be undefined)
460
+ * @param value - Value to set (checked for null/undefined)
461
+ */
462
+ setFieldIfExists(entity, fieldName, value) {
463
+ if (fieldName && value != null) {
464
+ entity.Set(fieldName, value);
253
465
  }
254
- for (const technology of technologies) {
255
- const categoryId = await this.GetOrCreateTechnologyCategoryId(params, technology.category, md, currentUser);
256
- const row = runViewResult.Results.find((r) => r.Category.trim().toLowerCase() === technology.category.trim().toLowerCase() && r.Technology.trim().toLowerCase() === technology.name.trim().toLowerCase());
257
- if (!row || row.length === 0) {
258
- const at = await md.GetEntityObject(ATEntity.EntityName, currentUser);
259
- at.Set(ATEntity.AccountIDFieldName, accountID);
260
- at.Set(ATEntity.TechnologyIDFieldName, await this.GetOrCreateTechnologyId(technology.name, categoryId, md, currentUser));
261
- bSuccess = bSuccess && await at.Save();
262
- }
263
- else {
264
- row.matchFound = true;
265
- }
266
- }
267
- // finally, go through all the rows in existingRows, and for any that have matchFound === true, skip
268
- // but for rest update them to have their EndedAt = NOW
269
- const fieldName = ATEntity.MatchFoundFieldName;
270
- for (const record of runViewResult.Results) {
271
- if (!record[fieldName]) {
272
- const entity = await md.GetEntityObject(fieldName, currentUser);
273
- entity.SetMany(record);
274
- entity.Set(ATEntity.EndedUseAtFieldName, new Date());
275
- const saveResult = await entity.Save();
276
- if (!saveResult) {
277
- (0, core_1.LogError)('Error updating Account Technology record with enriched data', undefined, entity.LatestResult);
466
+ }
467
+ /**
468
+ * Creates or updates technology records associated with an account
469
+ * Manages the relationship between accounts and their technology stack
470
+ * Marks old technologies as ended if no longer detected
471
+ *
472
+ * @param params - Processing parameters with technology entity configuration
473
+ * @param accountID - ID of the account to associate technologies with
474
+ * @param technologies - Array of technology data from Apollo
475
+ * @param md - Metadata instance for entity operations
476
+ * @param currentUser - User context for data access
477
+ * @returns Promise<boolean> indicating overall success/failure
478
+ */
479
+ async CreateAccountTechnologyRecords(params, accountID, technologies, md, currentUser) {
480
+ if (!params.AccountTechnologyEntity || !technologies || technologies.length === 0) {
481
+ return true;
482
+ }
483
+ try {
484
+ let bSuccess = true;
485
+ const ATEntity = params.AccountTechnologyEntity;
486
+ if (ATEntity.TechnologyIDField && ATEntity.TechnologyField && ATEntity.CategoryField) {
487
+ const rv = new core_1.RunView();
488
+ const runViewResult = await rv.RunView({
489
+ EntityName: ATEntity.EntityName,
490
+ ExtraFilter: `${ATEntity.AccountIDField} = ${accountID}`
491
+ }, currentUser);
492
+ if (!runViewResult.Success) {
493
+ (0, core_1.LogError)('Error querying for existing Account Technologies', undefined, runViewResult.ErrorMessage);
494
+ return false;
495
+ }
496
+ // Process each technology
497
+ for (const technology of technologies) {
498
+ const categoryId = await this.getOrCreateTechnologyCategoryId(params, technology.category, md, currentUser);
499
+ const existingRow = runViewResult.Results.find((r) => r[ATEntity.CategoryField]?.trim().toLowerCase() === technology.category.trim().toLowerCase() &&
500
+ r[ATEntity.TechnologyField]?.trim().toLowerCase() === technology.name.trim().toLowerCase());
501
+ if (!existingRow) {
502
+ // Create new technology record
503
+ const at = await md.GetEntityObject(ATEntity.EntityName, currentUser);
504
+ at.NewRecord();
505
+ at.Set(ATEntity.AccountIDField, accountID);
506
+ at.Set(ATEntity.TechnologyIDField, await this.getOrCreateTechnologyId(technology.name, categoryId, md, currentUser));
507
+ bSuccess = bSuccess && await at.Save();
508
+ }
509
+ else {
510
+ existingRow.matchFound = true;
511
+ }
512
+ }
513
+ // Mark unmatched technologies as ended
514
+ for (const record of runViewResult.Results) {
515
+ if (!record.matchFound) {
516
+ const entity = await md.GetEntityObject(ATEntity.EntityName, core_1.CompositeKey.FromID(record.ID), currentUser);
517
+ entity.Set(ATEntity.EndedUseAtField, new Date());
518
+ const saveResult = await entity.Save();
519
+ if (!saveResult) {
520
+ (0, core_1.LogError)('Error updating Account Technology record', undefined, entity.LatestResult);
521
+ }
522
+ bSuccess = bSuccess && saveResult;
523
+ }
278
524
  }
279
- bSuccess = bSuccess && saveResult;
280
525
  }
526
+ return bSuccess;
527
+ }
528
+ catch (error) {
529
+ (0, core_1.LogError)('Error creating account technology records', undefined, error);
530
+ return false;
281
531
  }
282
- return bSuccess;
283
532
  }
284
- async GetOrCreateTechnologyCategoryId(params, categoryName, md, currentUser) {
533
+ /**
534
+ * Gets or creates a technology category record by name
535
+ * Ensures technology categories exist before creating technology records
536
+ *
537
+ * @param params - Processing parameters with category entity configuration
538
+ * @param categoryName - Name of the technology category
539
+ * @param md - Metadata instance for entity operations
540
+ * @param currentUser - User context for data access
541
+ * @returns Promise<number> - ID of the category (0 if error)
542
+ */
543
+ async getOrCreateTechnologyCategoryId(params, categoryName, md, currentUser) {
285
544
  const TCEntity = params.TechnologyCategoryEntity;
286
545
  if (!TCEntity) {
287
546
  return 0;
288
547
  }
289
- const rv = new core_1.RunView();
290
- const runViewResult = await rv.RunView({
291
- EntityName: TCEntity.EntityName,
292
- ExtraFilter: `${TCEntity.NameFieldName} = '${this.EscapeSingleQuotes(categoryName)}'`
293
- }, currentUser);
294
- if (!runViewResult.Success) {
295
- (0, core_1.LogError)('Error querying for existing Technology Categories', undefined, runViewResult.ErrorMessage);
296
- return 0;
297
- }
298
- if (runViewResult.Results.length > 0) {
299
- return runViewResult.Results[0].ID;
548
+ try {
549
+ const rv = new core_1.RunView();
550
+ const runViewResult = await rv.RunView({
551
+ EntityName: TCEntity.EntityName,
552
+ ExtraFilter: `${TCEntity.NameField} = '${this.EscapeSingleQuotes(categoryName)}'`
553
+ }, currentUser);
554
+ if (!runViewResult.Success) {
555
+ (0, core_1.LogError)('Error querying for existing Technology Categories', undefined, runViewResult.ErrorMessage);
556
+ return 0;
557
+ }
558
+ if (runViewResult.Results.length > 0) {
559
+ return runViewResult.Results[0].ID;
560
+ }
561
+ // Create new record
562
+ const entity = await md.GetEntityObject(TCEntity.EntityName, currentUser);
563
+ entity.NewRecord();
564
+ entity.Set(TCEntity.NameField, categoryName);
565
+ const saveResult = await entity.Save();
566
+ if (!saveResult) {
567
+ (0, core_1.LogError)('Error creating new Technology Category record', undefined, entity.LatestResult);
568
+ return 0;
569
+ }
570
+ return entity.Get(TCEntity.IDField);
300
571
  }
301
- //create a new record
302
- const entity = await md.GetEntityObject(TCEntity.EntityName, currentUser);
303
- entity.Set(TCEntity.NameFieldName, categoryName);
304
- const saveResult = await entity.Save();
305
- if (!saveResult) {
306
- (0, core_1.LogError)('Error creating new Technology Category record', undefined, entity.LatestResult);
572
+ catch (error) {
573
+ (0, core_1.LogError)('Error getting or creating technology category', undefined, error);
307
574
  return 0;
308
575
  }
309
- return entity.Get(TCEntity.IDFieldName);
310
576
  }
311
- async GetOrCreateTechnologyId(name, categoryId, md, currentUser) {
312
- const rv = new core_1.RunView();
313
- const runViewResult = await rv.RunView({
314
- EntityName: 'Technologies',
315
- ExtraFilter: `Name = '${this.EscapeSingleQuotes(name)}' AND CategoryID = ${categoryId}`
316
- }, currentUser);
317
- if (!runViewResult.Success) {
318
- (0, core_1.LogError)('Error querying for existing Technologies', undefined, runViewResult.ErrorMessage);
319
- return 0;
320
- }
321
- if (runViewResult.Results.length > 0) {
322
- return runViewResult.Results[0].ID;
323
- }
324
- else {
577
+ /**
578
+ * Gets or creates a technology record by name and category
579
+ * Maintains referential integrity between technologies and categories
580
+ *
581
+ * @param name - Name of the technology
582
+ * @param categoryId - ID of the technology category
583
+ * @param md - Metadata instance for entity operations
584
+ * @param currentUser - User context for data access
585
+ * @returns Promise<number> - ID of the technology (0 if error)
586
+ */
587
+ async getOrCreateTechnologyId(name, categoryId, md, currentUser) {
588
+ try {
589
+ const rv = new core_1.RunView();
590
+ const runViewResult = await rv.RunView({
591
+ EntityName: 'Technologies',
592
+ ExtraFilter: `Name = '${this.EscapeSingleQuotes(name)}' AND CategoryID = ${categoryId}`
593
+ }, currentUser);
594
+ if (!runViewResult.Success) {
595
+ (0, core_1.LogError)('Error querying for existing Technologies', undefined, runViewResult.ErrorMessage);
596
+ return 0;
597
+ }
598
+ if (runViewResult.Results.length > 0) {
599
+ return runViewResult.Results[0].ID;
600
+ }
601
+ // Create new technology record
325
602
  const technologyEntity = await md.GetEntityObject('Technologies', currentUser);
603
+ technologyEntity.NewRecord();
326
604
  technologyEntity.Set('Name', name);
327
605
  technologyEntity.Set('CategoryID', categoryId);
328
606
  const saveResult = await technologyEntity.Save();
@@ -332,212 +610,342 @@ let ApolloAccountsEnrichmentAction = class ApolloAccountsEnrichmentAction extend
332
610
  }
333
611
  return technologyEntity.Get('ID');
334
612
  }
613
+ catch (error) {
614
+ (0, core_1.LogError)('Error getting or creating technology', undefined, error);
615
+ return 0;
616
+ }
335
617
  }
336
- async CreateAndEnrichPeople(params, accountID, domain, startRow, md, currentUser) {
337
- let APIparams = {
338
- api_key: Config.ApolloAPIKey,
339
- reveal_personal_emails: true,
340
- q_organization_domains: domain,
341
- page: 1
342
- };
343
- const headers = {
344
- 'Cache-Control': 'no-cache',
345
- 'Content-Type': 'application/json'
346
- };
347
- const response = await this.WrapApolloCall('post', 'mixed_people/search', APIparams, { headers: headers });
348
- if (!response || response.status !== 200) {
349
- (0, core_1.LogError)(`Error fetching data from Apollo.io: ${response.statusText}`);
350
- return false;
618
+ /**
619
+ * Searches for and enriches people associated with an organization's domain
620
+ * Uses Apollo's people search to find contacts at the organization
621
+ * Handles pagination to retrieve all available contacts (up to configured limit)
622
+ *
623
+ * @param params - Processing parameters with contact entity configuration
624
+ * @param accountID - ID of the account to associate contacts with
625
+ * @param domain - Organization domain to search for people
626
+ * @param startRow - Current row for progress tracking
627
+ * @param md - Metadata instance for entity operations
628
+ * @param currentUser - User context for data access
629
+ * @returns Promise<boolean> indicating success/failure of people processing
630
+ */
631
+ async createAndEnrichPeople(params, accountID, domain, startRow, md, currentUser) {
632
+ if (!params.ContactEntity) {
633
+ return true; // No contact processing needed
351
634
  }
352
- // here we want to iterate through all the results and update the contacts with the enriched data if
353
- // the API returned a match
354
- const data = response.data;
355
- const allPeople = data.people;
356
- for (let i = 1; i < data.pagination.total_pages && i < (Config.MaxPeopleToEnrichPerOrg / 10); i++) {
357
- //get additional pages
358
- APIparams.page = i + 1;
359
- const nextResponse = await this.WrapApolloCall('post', 'mixed_people/search', params, { headers: headers });
360
- if (nextResponse.status !== 200) {
361
- console.error(`Error fetching data from Apollo.io: ${nextResponse.statusText}`);
635
+ try {
636
+ const APIparams = {
637
+ api_key: Config.ApolloAPIKey,
638
+ reveal_personal_emails: true,
639
+ q_organization_domains: domain,
640
+ page: 1
641
+ };
642
+ const headers = {
643
+ 'Cache-Control': 'no-cache',
644
+ 'Content-Type': 'application/json'
645
+ };
646
+ const response = await this.WrapApolloCall('post', 'mixed_people/search', APIparams, { headers: headers });
647
+ if (!response || response.status !== 200) {
648
+ (0, core_1.LogError)(`Error fetching data from Apollo.io: ${response?.statusText}`);
362
649
  return false;
363
650
  }
364
- else {
365
- // here we want to iterate through all the results and update the contacts with the enriched data if
366
- // the API returned a match
651
+ const data = response.data;
652
+ const allPeople = data.people;
653
+ // Fetch additional pages
654
+ for (let i = 1; i < data.pagination.total_pages && i < (Config.MaxPeopleToEnrichPerOrg / 10); i++) {
655
+ APIparams.page = i + 1;
656
+ const nextResponse = await this.WrapApolloCall('post', 'mixed_people/search', APIparams, { headers: headers });
657
+ if (nextResponse.status !== 200) {
658
+ console.error(`Error fetching data from Apollo.io: ${nextResponse.statusText}`);
659
+ return false;
660
+ }
367
661
  const nextResult = nextResponse.data;
368
662
  allPeople.push(...nextResult.people);
369
663
  }
664
+ if (allPeople.length === 0) {
665
+ (0, core_1.LogStatus)(`No people found for this domain: ${domain}`);
666
+ return true;
667
+ }
668
+ // Process all people
669
+ let bSuccess = true;
670
+ for (let i = 0; i < allPeople.length; i++) {
671
+ const person = allPeople[i];
672
+ if (this.shouldSkipPerson(person, domain)) {
673
+ continue;
674
+ }
675
+ bSuccess = bSuccess && await this.createAndEnrichContact(params, accountID, person, md, currentUser, i, allPeople.length);
676
+ }
677
+ return bSuccess;
678
+ }
679
+ catch (error) {
680
+ (0, core_1.LogError)('Error creating and enriching people', undefined, error);
681
+ return false;
370
682
  }
371
- let bSuccess = true;
372
- if (allPeople.length === 0) {
373
- (0, core_1.LogStatus)(`No people found for this domain: ${domain}`);
683
+ }
684
+ /**
685
+ * Determines if a person should be skipped during contact creation
686
+ * Filters out people with missing data or excluded job titles
687
+ *
688
+ * @param person - Person data from Apollo search response
689
+ * @param domain - Expected domain for email validation
690
+ * @returns boolean - true if person should be skipped
691
+ */
692
+ shouldSkipPerson(person, domain) {
693
+ // Check for missing required data
694
+ if (!person.last_name || !person.email || person.email.split('@')[1] !== domain) {
695
+ (0, core_1.LogStatus)(`Skipping person: ${person.first_name} ${person.last_name} because email is missing or does not match domain`);
374
696
  return true;
375
697
  }
376
- let i = 0;
377
- for (const person of allPeople) {
378
- // first, check to make sure that the person has an email and the domain matches the
379
- // account domain, we don't want people who's primary email doesn't match the domain
380
- if (!person.last_name || !person.email || person.email.split('@')[1] !== domain) {
381
- // skip this person
382
- (0, core_1.LogStatus)(`Skipping person: ${person.first_name} ${person.last_name}, accountID: ${accountID} because email is missing or does not match domain`);
383
- }
384
- else if (this.IsExcludedTitle(person.title)) {
385
- (0, core_1.LogStatus)(`Skipping person: ${person.first_name} ${person.last_name}, accountID: ${accountID} because title is excluded`);
386
- }
387
- else {
388
- bSuccess = bSuccess && await this.CreateAndEnrichContact(params, accountID, person, md, currentUser, i, allPeople.length);
389
- }
390
- i++;
698
+ // Check for excluded titles
699
+ if (this.IsExcludedTitle(person.title)) {
700
+ (0, core_1.LogStatus)(`Skipping person: ${person.first_name} ${person.last_name} because title is excluded`);
701
+ return true;
391
702
  }
392
- return bSuccess;
703
+ return false;
393
704
  }
394
- async CreateAndEnrichContact(params, accountID, person, md, currentUser, sequence, total) {
705
+ /**
706
+ * Creates or updates a contact record with enriched data from Apollo
707
+ * Handles contact deduplication and data validation
708
+ *
709
+ * @param params - Processing parameters with contact entity configuration
710
+ * @param accountID - ID of the account to associate contact with
711
+ * @param person - Person data from Apollo search response
712
+ * @param md - Metadata instance for entity operations
713
+ * @param currentUser - User context for data access
714
+ * @param sequence - Current contact number for progress tracking
715
+ * @param total - Total contacts for progress tracking
716
+ * @returns Promise<boolean> indicating success/failure
717
+ */
718
+ async createAndEnrichContact(params, accountID, person, md, currentUser, sequence, total) {
395
719
  const CEntity = params.ContactEntity;
396
720
  if (!CEntity) {
397
721
  return false;
398
722
  }
399
723
  try {
400
724
  (0, core_1.LogStatus)(`Enriching contact for person: ${person.first_name} ${person.last_name}, accountID: ${accountID} (${sequence} of ${total})`);
401
- // first check to see if there is an existing contact record for this person
402
- const contact = await this.GetOrCreateContact(params, accountID, person, md, currentUser);
725
+ const contact = await this.getOrCreateContact(params, accountID, person, md, currentUser);
403
726
  if (!contact) {
404
727
  return false;
405
728
  }
406
- // now we have a contact entity object and we can enrich it.
407
- contact.Set(CEntity.FirstNameFieldName, person.first_name);
408
- contact.Set(CEntity.LastNameFieldName, person.last_name);
409
- contact.Set(CEntity.TitleFieldName, person.title);
410
- if (person.email) {
411
- contact.Set(CEntity.EmailFieldName, person.email);
412
- }
413
- contact.Set(CEntity.FacebookFieldName, person.facebook_url);
414
- contact.Set(CEntity.LinkedInFieldName, person.linkedin_url);
415
- contact.Set(CEntity.TwitterFieldName, person.twitter_url);
416
- //contact.Set("PhoneNumber", person.?.length > 0 ? person.phone_numbers[0].raw_number : null;
417
- contact.Set(CEntity.PhotoFieldName, person.photo_url);
418
- contact.Set(CEntity.LastEnrichedAtFieldName, new Date());
419
- const saveResult = contact.Save();
729
+ // Update contact with enriched data
730
+ this.updateContactFields(contact, person, CEntity);
731
+ const saveResult = await contact.Save();
420
732
  if (!saveResult) {
421
733
  (0, core_1.LogError)('Error updating contact record with enriched data', undefined, contact.LatestResult);
422
734
  return false;
423
735
  }
424
- return await contact.Save() && await this.CreateUpdateContactEmploymentAndEducationHistory(params, contact.Get("ID"), person, md, currentUser);
736
+ // Update employment and education history if configured
737
+ if (params.ContactEducationHistoryEntity) {
738
+ return await this.createUpdateContactEmploymentAndEducationHistory(params, contact.Get("ID"), person, md, currentUser);
739
+ }
740
+ return true;
425
741
  }
426
- catch (e) {
427
- (0, core_1.LogError)(e);
742
+ catch (error) {
743
+ (0, core_1.LogError)('Error creating and enriching contact', undefined, error);
428
744
  return false;
429
745
  }
430
746
  }
431
- async GetOrCreateContact(params, accountID, person, md, currentUser) {
747
+ /**
748
+ * Updates contact entity fields with person data from Apollo
749
+ * Maps Apollo person fields to the configured contact entity fields
750
+ *
751
+ * @param contact - Contact entity to update
752
+ * @param person - Person data from Apollo
753
+ * @param CEntity - Contact entity field name mappings
754
+ */
755
+ updateContactFields(contact, person, CEntity) {
756
+ contact.Set(CEntity.FirstNameField, person.first_name);
757
+ contact.Set(CEntity.LastNameField, person.last_name);
758
+ contact.Set(CEntity.TitleField, person.title);
759
+ if (person.email) {
760
+ contact.Set(CEntity.EmailField, person.email);
761
+ }
762
+ this.setFieldIfExists(contact, CEntity.FacebookField, person.facebook_url);
763
+ this.setFieldIfExists(contact, CEntity.LinkedInField, person.linkedin_url);
764
+ this.setFieldIfExists(contact, CEntity.TwitterField, person.twitter_url);
765
+ this.setFieldIfExists(contact, CEntity.ProfilePictureURLField, person.photo_url);
766
+ contact.Set(CEntity.EnrichedAtField, new Date());
767
+ }
768
+ /**
769
+ * Gets an existing contact or creates a new one with conflict detection
770
+ * Prevents duplicate contacts across different accounts
771
+ * Supports matching by email or name combination
772
+ *
773
+ * @param params - Processing parameters with contact entity configuration
774
+ * @param accountID - ID of the account to associate contact with
775
+ * @param person - Person data from Apollo
776
+ * @param md - Metadata instance for entity operations
777
+ * @param currentUser - User context for data access
778
+ * @returns Promise<BaseEntity | null> - Contact entity or null if conflict/error
779
+ */
780
+ async getOrCreateContact(params, accountID, person, md, currentUser) {
432
781
  const CEntity = params.ContactEntity;
433
782
  if (!CEntity) {
434
783
  return null;
435
784
  }
436
- const rv = new core_1.RunView();
437
- const runViewResult = await rv.RunView({
438
- EntityName: CEntity.EntityName,
439
- ExtraFilter: `${CEntity.EmailFieldName} = '${this.EscapeSingleQuotes(person.email)}' AND ${CEntity.AccountIDFieldName} <> ${accountID}`
440
- }, currentUser);
441
- if (!runViewResult.Success) {
442
- (0, core_1.LogError)('Error querying for existing Contacts', undefined, runViewResult.ErrorMessage);
443
- return null;
444
- }
445
- if (runViewResult.Results.length > 0) {
446
- const results = runViewResult.Results;
447
- (0, core_1.LogError)(`*** SKIPPING new contact record for ${person.first_name} ${person.last_name} because they are already associated with AccountID: ${results[0].AccountID} - ContactID: ${results[0].ID}`);
448
- return null;
449
- }
450
- // ok, now that we know this person is not associated with another account, check to see if there is an existing record in the vwContacts view FOR this account, could do this more efficiently with a single SQL
451
- // query, but this is easier to read and understand
452
- const contactsViewResult = await rv.RunView({
453
- EntityName: CEntity.EntityName,
454
- ExtraFilter: `(${CEntity.EmailFieldName} = '${this.EscapeSingleQuotes(person.email)}' OR (${CEntity.FirstNameFieldName} = '${this.EscapeSingleQuotes(person.first_name)}' AND ${CEntity.LastNameFieldName} = '${this.EscapeSingleQuotes(person.last_name)}')) AND
455
- ${CEntity.AccountIDFieldName} = ${accountID}`
456
- }, currentUser);
457
- if (!contactsViewResult.Success) {
458
- (0, core_1.LogError)('Error querying for existing Contacts', undefined, contactsViewResult.ErrorMessage);
785
+ try {
786
+ const rv = new core_1.RunView();
787
+ // Check if contact exists with different account
788
+ const conflictResult = await rv.RunView({
789
+ EntityName: CEntity.EntityName,
790
+ ExtraFilter: `${CEntity.EmailField} = '${this.EscapeSingleQuotes(person.email)}' AND ${CEntity.AccountIDField} <> ${accountID}`
791
+ }, currentUser);
792
+ if (!conflictResult.Success) {
793
+ (0, core_1.LogError)('Error querying for existing Contacts', undefined, conflictResult.ErrorMessage);
794
+ return null;
795
+ }
796
+ if (conflictResult.Results.length > 0) {
797
+ const existing = conflictResult.Results[0];
798
+ (0, core_1.LogError)(`*** SKIPPING new contact record for ${person.first_name} ${person.last_name} because they are already associated with AccountID: ${existing.AccountID} - ContactID: ${existing.ID}`);
799
+ return null;
800
+ }
801
+ // Check if contact exists for this account
802
+ const existingResult = await rv.RunView({
803
+ EntityName: CEntity.EntityName,
804
+ ExtraFilter: `(${CEntity.EmailField} = '${this.EscapeSingleQuotes(person.email)}' OR (${CEntity.FirstNameField} = '${this.EscapeSingleQuotes(person.first_name)}' AND ${CEntity.LastNameField} = '${this.EscapeSingleQuotes(person.last_name)}')) AND ${CEntity.AccountIDField} = ${accountID}`
805
+ }, currentUser);
806
+ if (!existingResult.Success) {
807
+ (0, core_1.LogError)('Error querying for existing Contacts', undefined, existingResult.ErrorMessage);
808
+ return null;
809
+ }
810
+ let contactEntity = null;
811
+ if (existingResult.Results.length > 0) {
812
+ // Update existing contact
813
+ contactEntity = await md.GetEntityObject(CEntity.EntityName, existingResult.Results[0].ID, currentUser);
814
+ contactEntity.Set(CEntity.EmailField, person.email);
815
+ }
816
+ else {
817
+ // Create new contact
818
+ (0, core_1.LogStatus)(` > Creating new contact record for ${person.first_name} ${person.last_name}`);
819
+ contactEntity = await md.GetEntityObject(CEntity.EntityName, currentUser);
820
+ contactEntity.NewRecord();
821
+ contactEntity.Set(CEntity.EmailField, person.email);
822
+ contactEntity.Set(CEntity.AccountIDField, accountID);
823
+ contactEntity.Set(CEntity.ActivityCountField, 0);
824
+ contactEntity.Set(CEntity.EmailSourceField, Config.EmailSourceName);
825
+ }
826
+ return contactEntity;
827
+ }
828
+ catch (error) {
829
+ (0, core_1.LogError)('Error getting or creating contact', undefined, error);
459
830
  return null;
460
831
  }
461
- const contactEntity = await md.GetEntityObject(CEntity.EntityName, currentUser);
462
- if (contactsViewResult.Results.length > 0) {
463
- contactEntity.SetMany(contactsViewResult.Results[0]);
464
- contactEntity.Set(CEntity.EmailFieldName, person.email);
465
- }
466
- else {
467
- (0, core_1.LogStatus)(` > Creating new contact record for ${person.first_name} ${person.last_name}`);
468
- contactEntity.NewRecord();
469
- contactEntity.Set(CEntity.EmailFieldName, person.email);
470
- contactEntity.Set(CEntity.AccountIDFieldName, accountID);
471
- contactEntity.Set(CEntity.ActivityCountFieldName, 0);
472
- contactEntity.Set(CEntity.EmailSourceFieldName, Config.EmailSourceName);
473
- }
474
- return contactEntity;
475
832
  }
476
- async CreateUpdateContactEmploymentAndEducationHistory(params, contactID, person, md, currentUser) {
833
+ /**
834
+ * Creates or updates employment and education history records for a contact
835
+ * Processes Apollo employment history data to create education records
836
+ *
837
+ * @param params - Processing parameters with education entity configuration
838
+ * @param contactID - ID of the contact to create history for
839
+ * @param person - Person data containing employment history
840
+ * @param md - Metadata instance for entity operations
841
+ * @param currentUser - User context for data access
842
+ * @returns Promise<boolean> indicating success/failure
843
+ */
844
+ async createUpdateContactEmploymentAndEducationHistory(params, contactID, person, md, currentUser) {
477
845
  const CEntity = params.ContactEducationHistoryEntity;
478
- if (!CEntity) {
479
- return false;
846
+ if (!CEntity || !person.employment_history) {
847
+ return true;
480
848
  }
481
- let bSuccess = true;
482
- const rv = new core_1.RunView();
483
- for (const history of person.employment_history) {
484
- if (history.degree && history.degree.length > 0) {
485
- const EHRunViewResult = await rv.RunView({
486
- EntityName: CEntity.EntityName,
487
- ExtraFilter: `${CEntity.ContactIDFieldName} = ${contactID} AND ${CEntity.InstitutionFieldName} = '${this.EscapeSingleQuotes(history.organization_name)}' AND ${CEntity.DegreeFieldName} = '${this.EscapeSingleQuotes(history.degree)}'`
488
- }, currentUser);
489
- if (!EHRunViewResult.Success) {
490
- (0, core_1.LogError)('Error querying for existing Employment History', undefined, EHRunViewResult.ErrorMessage);
491
- bSuccess = false;
492
- }
493
- const historyEntity = await md.GetEntityObject(CEntity.EntityName, currentUser);
494
- if (EHRunViewResult.Results.length > 0) {
495
- historyEntity.SetMany(EHRunViewResult.Results[0]);
496
- }
497
- else {
498
- historyEntity.NewRecord();
499
- historyEntity.Set(CEntity.ContactIDFieldName, contactID);
500
- }
501
- historyEntity.Set(CEntity.InstitutionFieldName, history.organization_name);
502
- historyEntity.Set(CEntity.DegreeFieldName, history.degree);
503
- historyEntity.Set(CEntity.GradeLevelFieldName, !isNaN(history.grade_level) ? parseInt(history.grade_level) : 0);
504
- if (this.IsValidDate(history.start_date)) {
505
- historyEntity.Set(CEntity.StartDateFieldName, new Date(history.start_date));
506
- }
507
- if (this.IsValidDate(history.end_date)) {
508
- historyEntity.Set(CEntity.EndDateFieldName, new Date(history.end_date));
509
- }
510
- historyEntity.Set(CEntity.IsCurrentFieldName, history.current);
511
- const saveResult = await historyEntity.Save();
512
- if (!saveResult) {
513
- (0, core_1.LogError)('Error saving Employment History record', undefined, historyEntity.LatestResult);
514
- bSuccess = false;
849
+ try {
850
+ let bSuccess = true;
851
+ const rv = new core_1.RunView();
852
+ for (const history of person.employment_history) {
853
+ if (history.degree && history.degree.length > 0) {
854
+ const EHRunViewResult = await rv.RunView({
855
+ EntityName: CEntity.EntityName,
856
+ ExtraFilter: `${CEntity.ContactIDField} = ${contactID} AND ${CEntity.InstitutionField} = '${this.EscapeSingleQuotes(history.organization_name)}' AND ${CEntity.DegreeField} = '${this.EscapeSingleQuotes(history.degree)}'`
857
+ }, currentUser);
858
+ if (!EHRunViewResult.Success) {
859
+ (0, core_1.LogError)('Error querying for existing Employment History', undefined, EHRunViewResult.ErrorMessage);
860
+ bSuccess = false;
861
+ continue;
862
+ }
863
+ let historyEntity = null;
864
+ if (EHRunViewResult.Results.length > 0) {
865
+ historyEntity = await md.GetEntityObject(CEntity.EntityName, core_1.CompositeKey.FromID(EHRunViewResult.Results[0].ID), currentUser);
866
+ }
867
+ else {
868
+ historyEntity = await md.GetEntityObject(CEntity.EntityName, currentUser);
869
+ historyEntity.NewRecord();
870
+ historyEntity.Set(CEntity.ContactIDField, contactID);
871
+ }
872
+ // Set history data
873
+ historyEntity.Set(CEntity.InstitutionField, history.organization_name);
874
+ historyEntity.Set(CEntity.DegreeField, history.degree);
875
+ historyEntity.Set(CEntity.GradeLevelField, !isNaN(history.grade_level) ? parseInt(history.grade_level) : 0);
876
+ if (this.IsValidDate(history.start_date)) {
877
+ historyEntity.Set(CEntity.StartDateField, new Date(history.start_date));
878
+ }
879
+ if (this.IsValidDate(history.end_date)) {
880
+ historyEntity.Set(CEntity.EndDateField, new Date(history.end_date));
881
+ }
882
+ historyEntity.Set(CEntity.IsCurrentField, history.current);
883
+ const saveResult = await historyEntity.Save();
884
+ if (!saveResult) {
885
+ (0, core_1.LogError)('Error saving Employment History record', undefined, historyEntity.LatestResult);
886
+ bSuccess = false;
887
+ }
515
888
  }
516
889
  }
890
+ return bSuccess;
891
+ }
892
+ catch (error) {
893
+ (0, core_1.LogError)('Error creating/updating contact employment and education history', undefined, error);
894
+ return false;
517
895
  }
518
- return bSuccess;
519
896
  }
897
+ /**
898
+ * Validates if a date string represents a valid date
899
+ * Used for employment/education history date validation
900
+ *
901
+ * @param dateString - Date string to validate
902
+ * @returns boolean - true if valid date
903
+ */
520
904
  IsValidDate(dateString) {
521
905
  if (dateString && dateString.length > 0) {
522
906
  const date = new Date(dateString);
523
907
  return date instanceof Date && !isNaN(date.getTime());
524
908
  }
525
- else {
526
- return false;
527
- }
909
+ return false;
528
910
  }
911
+ /**
912
+ * Checks if a job title should be excluded from contact creation
913
+ * Filters out non-business contacts like students and volunteers
914
+ *
915
+ * @param title - Job title to check
916
+ * @returns boolean - true if title should be excluded
917
+ */
529
918
  IsExcludedTitle(title) {
530
919
  if (!title) {
531
920
  return false;
532
921
  }
533
922
  return this.ExcludeTitles.includes(title.toLowerCase());
534
923
  }
924
+ /**
925
+ * Escapes single quotes in strings for SQL query safety
926
+ * Prevents SQL injection in dynamic filter construction
927
+ *
928
+ * @param str - String to escape
929
+ * @returns string - Escaped string with doubled single quotes
930
+ */
535
931
  EscapeSingleQuotes(str) {
536
932
  if (str) {
537
933
  return str.replace(/'/g, "''");
538
934
  }
539
935
  return '';
540
936
  }
937
+ /**
938
+ * Wraps Apollo.io API calls with retry logic for rate limiting
939
+ * Handles 429 (Too Many Requests) responses with exponential backoff
940
+ *
941
+ * @param method - HTTP method ('get' or 'post')
942
+ * @param endpoint - Apollo API endpoint path
943
+ * @param data - Request payload for POST requests
944
+ * @param config - Axios configuration object
945
+ * @param retryAttempts - Number of retry attempts remaining (default: 1)
946
+ * @returns Promise<AxiosResponse> - Apollo API response
947
+ * @throws Error if rate limit exceeded or other API errors
948
+ */
541
949
  async WrapApolloCall(method, endpoint, data, config, retryAttempts = 1) {
542
950
  try {
543
951
  const url = `${Config.ApolloAPIEndpoint}/${endpoint}`;
@@ -571,15 +979,27 @@ let ApolloAccountsEnrichmentAction = class ApolloAccountsEnrichmentAction extend
571
979
  }
572
980
  }
573
981
  }
982
+ /**
983
+ * Utility method for creating delays in async operations
984
+ * Used for rate limit backoff and API throttling
985
+ *
986
+ * @param ms - Number of milliseconds to wait
987
+ * @returns Promise that resolves after the specified delay
988
+ */
574
989
  async Timeout(ms) {
575
990
  return new Promise(resolve => setTimeout(resolve, ms));
576
991
  }
577
992
  };
578
- exports.ApolloAccountsEnrichmentAction = ApolloAccountsEnrichmentAction;
579
- exports.ApolloAccountsEnrichmentAction = ApolloAccountsEnrichmentAction = __decorate([
580
- (0, global_1.RegisterClass)(actions_1.BaseAction, "Apollo Enrichment - Accounts")
581
- ], ApolloAccountsEnrichmentAction);
993
+ exports.ApolloEnrichmentAccountsAction = ApolloEnrichmentAccountsAction;
994
+ exports.ApolloEnrichmentAccountsAction = ApolloEnrichmentAccountsAction = __decorate([
995
+ (0, global_1.RegisterClass)(actions_1.BaseAction, "ApolloEnrichmentAccountsAction")
996
+ ], ApolloEnrichmentAccountsAction);
997
+ /**
998
+ * Loader function to ensure the ApolloEnrichmentAccountsAction class is included in the bundle
999
+ * Prevents tree shaking from removing this action class
1000
+ */
582
1001
  function LoadApolloAccountsEnrichmentAction() {
1002
+ // Stub function to prevent tree shaking
583
1003
  }
584
1004
  exports.LoadApolloAccountsEnrichmentAction = LoadApolloAccountsEnrichmentAction;
585
1005
  //# sourceMappingURL=accounts.js.map