@dench.com/cli 0.4.8 → 0.4.9
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/agent-config.ts +645 -0
- package/crm.ts +74 -8
- package/lib/enrichment-gateway.ts +1063 -42
- package/members.ts +192 -0
- package/package.json +3 -1
|
@@ -36,10 +36,16 @@ export type EnrichPersonBody = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
export type EnrichCompanyBody = {
|
|
39
|
-
domain
|
|
39
|
+
domain?: string;
|
|
40
|
+
companyName?: string;
|
|
41
|
+
linkedinUrl?: string;
|
|
40
42
|
requiredFields?: string[];
|
|
41
43
|
};
|
|
42
44
|
|
|
45
|
+
export type ReverseEmailBody = {
|
|
46
|
+
email: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
43
49
|
export type SearchPeopleBody = {
|
|
44
50
|
personTitles?: string[];
|
|
45
51
|
personLocations?: string[];
|
|
@@ -56,8 +62,21 @@ export type EnrichmentGatewayOptions = {
|
|
|
56
62
|
baseUrl?: string;
|
|
57
63
|
env?: EnrichmentGatewayEnv;
|
|
58
64
|
fetchImpl?: FetchLike;
|
|
65
|
+
/** Override the poll interval (ms) for testing. Default: 2000. */
|
|
66
|
+
pollIntervalMs?: number;
|
|
67
|
+
/** Override the poll timeout (ms) for testing. Default: 30000. */
|
|
68
|
+
pollTimeoutMs?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Optional abort signal — propagated to fetch and short-circuits the polling
|
|
71
|
+
* loop so callers (e.g. SSE clients that disconnect) can stop in-flight work.
|
|
72
|
+
*/
|
|
73
|
+
signal?: AbortSignal;
|
|
59
74
|
};
|
|
60
75
|
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// URL / key resolution (unchanged for backward compat)
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
61
80
|
export function resolveEnrichmentGatewayBaseUrl(
|
|
62
81
|
env: EnrichmentGatewayEnv = process.env,
|
|
63
82
|
): string {
|
|
@@ -108,33 +127,308 @@ export function buildPersonBodyFromIdentifier(
|
|
|
108
127
|
);
|
|
109
128
|
}
|
|
110
129
|
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// requiredFields → enrichFields translation
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
type EnrichField = "work_emails" | "personal_emails" | "phones";
|
|
135
|
+
|
|
136
|
+
function mapRequiredFieldsToEnrichFields(rf?: string[]): EnrichField[] {
|
|
137
|
+
const set = new Set<EnrichField>();
|
|
138
|
+
for (const f of rf ?? []) {
|
|
139
|
+
if (f === "phone") set.add("phones");
|
|
140
|
+
else if (f === "email" || f === "work_email") set.add("work_emails");
|
|
141
|
+
else if (f === "personal_email") set.add("personal_emails");
|
|
142
|
+
// profile-only fields (fullName, linkedinID, headline, URLs, location, etc.) → ignored
|
|
143
|
+
}
|
|
144
|
+
// If no contact-data fields requested, default to work_emails so FE
|
|
145
|
+
// returns at minimum profile + cheapest contact charge.
|
|
146
|
+
return set.size > 0 ? [...set] : ["work_emails"];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Apollo-shape envelope (preserves crm-enrichment catalog extraction paths)
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
export type ApolloPersonEnvelope = {
|
|
154
|
+
person: {
|
|
155
|
+
name?: string;
|
|
156
|
+
first_name?: string;
|
|
157
|
+
last_name?: string;
|
|
158
|
+
email?: string;
|
|
159
|
+
work_email?: string;
|
|
160
|
+
personal_email?: string;
|
|
161
|
+
linkedin_url?: string;
|
|
162
|
+
title?: string;
|
|
163
|
+
headline?: string;
|
|
164
|
+
contact: {
|
|
165
|
+
phone_numbers: Array<{
|
|
166
|
+
sanitized_number: string;
|
|
167
|
+
raw_number?: string;
|
|
168
|
+
}>;
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
type EnrichedPersonUpsertLike = {
|
|
174
|
+
fullName?: string;
|
|
175
|
+
firstName?: string;
|
|
176
|
+
lastName?: string;
|
|
177
|
+
headline?: string;
|
|
178
|
+
/** Structured title from the person's current role (distinct from headline). */
|
|
179
|
+
currentTitle?: string;
|
|
180
|
+
linkedinID?: string;
|
|
181
|
+
emails?: Array<{ address?: string; normalizedAddress?: string; type?: string }>;
|
|
182
|
+
phones?: Array<{ number?: string; normalizedNumber?: string }>;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
function shapePersonToApolloEnvelope(
|
|
186
|
+
u: EnrichedPersonUpsertLike,
|
|
187
|
+
): ApolloPersonEnvelope {
|
|
188
|
+
const workEmail = u.emails?.find((e) => e.type === "work")?.address;
|
|
189
|
+
const personalEmail = u.emails?.find((e) => e.type === "personal")?.address;
|
|
190
|
+
const anyEmail = u.emails?.[0]?.address;
|
|
191
|
+
return {
|
|
192
|
+
person: {
|
|
193
|
+
name: u.fullName,
|
|
194
|
+
first_name: u.firstName,
|
|
195
|
+
last_name: u.lastName,
|
|
196
|
+
email: workEmail ?? anyEmail,
|
|
197
|
+
work_email: workEmail,
|
|
198
|
+
personal_email: personalEmail,
|
|
199
|
+
linkedin_url: u.linkedinID
|
|
200
|
+
? `https://www.linkedin.com/in/${u.linkedinID}`
|
|
201
|
+
: undefined,
|
|
202
|
+
title: u.currentTitle,
|
|
203
|
+
headline: u.headline,
|
|
204
|
+
contact: {
|
|
205
|
+
phone_numbers: (u.phones ?? []).map((p) => ({
|
|
206
|
+
sanitized_number: p.normalizedNumber ?? p.number ?? "",
|
|
207
|
+
raw_number: p.number,
|
|
208
|
+
})),
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Organization-shape envelope (preserves COMPANY_ENRICHMENT_COLUMNS paths)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export type ApolloCompanyEnvelope = {
|
|
219
|
+
organization: {
|
|
220
|
+
name?: string;
|
|
221
|
+
domain?: string;
|
|
222
|
+
website_url?: string;
|
|
223
|
+
industry?: string;
|
|
224
|
+
industry_list?: string[];
|
|
225
|
+
specialties?: string[];
|
|
226
|
+
linkedin_url?: string;
|
|
227
|
+
founded_year?: number;
|
|
228
|
+
headcount?: number;
|
|
229
|
+
headcount_range?: string;
|
|
230
|
+
total_funding?: number;
|
|
231
|
+
description?: string;
|
|
232
|
+
hq_city?: string;
|
|
233
|
+
hq_country?: string;
|
|
234
|
+
hq_country_code?: string;
|
|
235
|
+
hq_location?: string;
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
type EnrichedCompanyUpsertLike = {
|
|
240
|
+
name?: string;
|
|
241
|
+
website?: string;
|
|
242
|
+
industry?: string;
|
|
243
|
+
industryList?: string[];
|
|
244
|
+
specialties?: string[];
|
|
245
|
+
linkedinUrl?: string;
|
|
246
|
+
founded?: number;
|
|
247
|
+
headcount?: number;
|
|
248
|
+
headcountRange?: string;
|
|
249
|
+
totalFunding?: number;
|
|
250
|
+
description?: string;
|
|
251
|
+
hqAddress?: {
|
|
252
|
+
city?: string;
|
|
253
|
+
region?: string;
|
|
254
|
+
country?: string;
|
|
255
|
+
countryCode?: string;
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function shapeCompanyToApolloEnvelope(
|
|
260
|
+
c: EnrichedCompanyUpsertLike,
|
|
261
|
+
): ApolloCompanyEnvelope {
|
|
262
|
+
const hq = c.hqAddress;
|
|
263
|
+
const city = hq?.city;
|
|
264
|
+
const country = hq?.country ?? hq?.countryCode;
|
|
265
|
+
const hqLocationParts = [city, country].filter(
|
|
266
|
+
(v): v is string => typeof v === "string" && v.length > 0,
|
|
267
|
+
);
|
|
268
|
+
return {
|
|
269
|
+
organization: {
|
|
270
|
+
name: c.name,
|
|
271
|
+
domain: c.website,
|
|
272
|
+
website_url: c.website,
|
|
273
|
+
industry: c.industry,
|
|
274
|
+
industry_list: c.industryList,
|
|
275
|
+
specialties: c.specialties,
|
|
276
|
+
linkedin_url: c.linkedinUrl,
|
|
277
|
+
founded_year: c.founded,
|
|
278
|
+
headcount: c.headcount,
|
|
279
|
+
headcount_range: c.headcountRange,
|
|
280
|
+
total_funding: c.totalFunding,
|
|
281
|
+
description: c.description,
|
|
282
|
+
hq_city: city,
|
|
283
|
+
hq_country: hq?.country,
|
|
284
|
+
hq_country_code: hq?.countryCode,
|
|
285
|
+
hq_location:
|
|
286
|
+
hqLocationParts.length > 0 ? hqLocationParts.join(", ") : undefined,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// enrichPerson — calls POST /v1/enrichment/person/contact with block-and-poll
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
111
295
|
export async function enrichPerson(
|
|
112
296
|
body: EnrichPersonBody,
|
|
113
297
|
options: EnrichmentGatewayOptions = {},
|
|
114
298
|
): Promise<Record<string, unknown>> {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (body.
|
|
120
|
-
if (body.
|
|
121
|
-
if (body.
|
|
122
|
-
if (body.
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
299
|
+
const enrichFields = mapRequiredFieldsToEnrichFields(body.requiredFields);
|
|
300
|
+
|
|
301
|
+
// Build the contact payload for the new endpoint.
|
|
302
|
+
const contactPayload: Record<string, unknown> = {};
|
|
303
|
+
if (body.linkedinUrl) contactPayload.linkedinUrl = body.linkedinUrl;
|
|
304
|
+
if (body.firstName) contactPayload.firstName = body.firstName;
|
|
305
|
+
if (body.lastName) contactPayload.lastName = body.lastName;
|
|
306
|
+
if (body.domain) {
|
|
307
|
+
contactPayload.domain = extractDomain(body.domain) ?? body.domain;
|
|
308
|
+
} else if (body.email) {
|
|
309
|
+
// Derive domain from email as a fallback input for person/contact.
|
|
310
|
+
const emailDomain = body.email.split("@")[1]?.trim();
|
|
311
|
+
if (emailDomain) contactPayload.domain = emailDomain;
|
|
312
|
+
}
|
|
313
|
+
if (body.organizationName) contactPayload.companyName = body.organizationName;
|
|
314
|
+
contactPayload.enrichFields = enrichFields;
|
|
315
|
+
|
|
316
|
+
// Validate we have enough to call person/contact.
|
|
317
|
+
const hasLinkedin = Boolean(contactPayload.linkedinUrl);
|
|
318
|
+
const hasNameAndCompany =
|
|
319
|
+
Boolean(contactPayload.firstName) &&
|
|
320
|
+
Boolean(contactPayload.lastName) &&
|
|
321
|
+
Boolean(contactPayload.domain || contactPayload.companyName);
|
|
322
|
+
if (!hasLinkedin && !hasNameAndCompany) {
|
|
127
323
|
throw new EnrichmentGatewayError(
|
|
128
|
-
"People enrichment requires
|
|
324
|
+
"People enrichment requires linkedinUrl OR firstName+lastName+domain.",
|
|
129
325
|
{ code: "missing_people_identifier" },
|
|
130
326
|
);
|
|
131
327
|
}
|
|
132
328
|
|
|
133
|
-
|
|
329
|
+
const requestBody = {
|
|
330
|
+
contacts: [contactPayload],
|
|
331
|
+
preferCache: true,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const raw = await callGatewayJson("/v1/enrichment/person/contact", {
|
|
134
335
|
method: "POST",
|
|
135
|
-
body:
|
|
336
|
+
body: requestBody,
|
|
136
337
|
options,
|
|
338
|
+
expectedStatus: [200, 202],
|
|
137
339
|
});
|
|
340
|
+
|
|
341
|
+
const personContactResp = raw as {
|
|
342
|
+
enrichmentId?: string | null;
|
|
343
|
+
status?: string;
|
|
344
|
+
cachedResults?: unknown[];
|
|
345
|
+
queuedCount?: number;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Cache hit: shape and return immediately.
|
|
349
|
+
const cachedResults = personContactResp.cachedResults ?? [];
|
|
350
|
+
if (cachedResults.length > 0) {
|
|
351
|
+
const hit = cachedResults[0] as EnrichedPersonUpsertLike;
|
|
352
|
+
return {
|
|
353
|
+
_meta: { cacheHit: true },
|
|
354
|
+
...shapePersonToApolloEnvelope(hit),
|
|
355
|
+
} as Record<string, unknown>;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Queued: poll for the result.
|
|
359
|
+
const enrichmentId = personContactResp.enrichmentId;
|
|
360
|
+
if (!enrichmentId) {
|
|
361
|
+
throw new EnrichmentGatewayError("No data returned", {
|
|
362
|
+
code: "not_found",
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return pollForPersonResult(enrichmentId, options);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function pollForPersonResult(
|
|
370
|
+
enrichmentId: string,
|
|
371
|
+
options: EnrichmentGatewayOptions,
|
|
372
|
+
): Promise<Record<string, unknown>> {
|
|
373
|
+
const pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
374
|
+
// Default 90s: FullEnrich jobs for hard-to-find contacts can take 40–120s.
|
|
375
|
+
// 30s was too short and caused spurious "No data returned" FatalErrors.
|
|
376
|
+
const pollTimeoutMs = options.pollTimeoutMs ?? 90000;
|
|
377
|
+
const deadline = Date.now() + pollTimeoutMs;
|
|
378
|
+
|
|
379
|
+
while (Date.now() < deadline) {
|
|
380
|
+
await sleep(pollIntervalMs, options.signal);
|
|
381
|
+
|
|
382
|
+
let raw: Record<string, unknown>;
|
|
383
|
+
try {
|
|
384
|
+
raw = await callGatewayJson(
|
|
385
|
+
`/v1/enrichment/jobs/${encodeURIComponent(enrichmentId)}`,
|
|
386
|
+
{ method: "GET", options },
|
|
387
|
+
);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
// 404 means the webhook hasn't arrived and been persisted yet — treat as
|
|
390
|
+
// "still pending" and keep polling until the deadline.
|
|
391
|
+
// 503 means the gateway is temporarily unavailable — same treatment.
|
|
392
|
+
// Any other error (auth failure, malformed request, etc.) is fatal.
|
|
393
|
+
if (
|
|
394
|
+
err instanceof EnrichmentGatewayError &&
|
|
395
|
+
(err.status === 404 || err.status === 503)
|
|
396
|
+
) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const jobResp = raw as {
|
|
403
|
+
status?: string;
|
|
404
|
+
people?: EnrichedPersonUpsertLike[];
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
if (jobResp.status === "succeeded") {
|
|
408
|
+
const people = jobResp.people ?? [];
|
|
409
|
+
if (people.length === 0) {
|
|
410
|
+
throw new EnrichmentGatewayError("No data returned", {
|
|
411
|
+
code: "not_found",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const person = people[0] as EnrichedPersonUpsertLike;
|
|
415
|
+
return {
|
|
416
|
+
_meta: { cacheHit: false, enrichmentId },
|
|
417
|
+
...shapePersonToApolloEnvelope(person),
|
|
418
|
+
} as Record<string, unknown>;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (jobResp.status === "failed") {
|
|
422
|
+
throw new EnrichmentGatewayError("No data returned", {
|
|
423
|
+
code: "not_found",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// status === "pending" — keep polling
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Timed out.
|
|
431
|
+
throw new EnrichmentGatewayError("No data returned", { code: "not_found" });
|
|
138
432
|
}
|
|
139
433
|
|
|
140
434
|
export async function enrichPersonByIdentifier(
|
|
@@ -142,51 +436,748 @@ export async function enrichPersonByIdentifier(
|
|
|
142
436
|
requiredFields?: string[],
|
|
143
437
|
options: EnrichmentGatewayOptions = {},
|
|
144
438
|
): Promise<Record<string, unknown>> {
|
|
145
|
-
return enrichPerson(
|
|
439
|
+
return enrichPerson(
|
|
440
|
+
buildPersonBodyFromIdentifier(identifier, requiredFields),
|
|
441
|
+
options,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Bulk helpers — submit one gateway request per chunk, poll once per job,
|
|
447
|
+
// and return a per-input-index result map. Used by the streaming enrich
|
|
448
|
+
// route to avoid waiting 30–90s per row.
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
/** FullEnrich's hard cap on contacts per bulk request. */
|
|
452
|
+
const FULLENRICH_BULK_CHUNK_SIZE = 100;
|
|
453
|
+
|
|
454
|
+
export type BulkPersonResultEntry =
|
|
455
|
+
| { ok: true; data: Record<string, unknown> }
|
|
456
|
+
| { ok: false; error: EnrichmentGatewayError };
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Build a contact payload identical to what `enrichPerson` sends, but without
|
|
460
|
+
* actually firing the request. Used by the bulk path so the route can validate
|
|
461
|
+
* inputs row-by-row before submitting a single bulk request.
|
|
462
|
+
*/
|
|
463
|
+
export function buildContactPayload(
|
|
464
|
+
body: EnrichPersonBody,
|
|
465
|
+
): Record<string, unknown> {
|
|
466
|
+
const enrichFields = mapRequiredFieldsToEnrichFields(body.requiredFields);
|
|
467
|
+
const contact: Record<string, unknown> = { enrichFields };
|
|
468
|
+
if (body.linkedinUrl) contact.linkedinUrl = body.linkedinUrl;
|
|
469
|
+
if (body.firstName) contact.firstName = body.firstName;
|
|
470
|
+
if (body.lastName) contact.lastName = body.lastName;
|
|
471
|
+
if (body.domain) {
|
|
472
|
+
contact.domain = extractDomain(body.domain) ?? body.domain;
|
|
473
|
+
} else if (body.email) {
|
|
474
|
+
const emailDomain = body.email.split("@")[1]?.trim();
|
|
475
|
+
if (emailDomain) contact.domain = emailDomain;
|
|
476
|
+
}
|
|
477
|
+
if (body.organizationName) contact.companyName = body.organizationName;
|
|
478
|
+
|
|
479
|
+
const hasLinkedin = Boolean(contact.linkedinUrl);
|
|
480
|
+
const hasNameAndCompany =
|
|
481
|
+
Boolean(contact.firstName) &&
|
|
482
|
+
Boolean(contact.lastName) &&
|
|
483
|
+
Boolean(contact.domain || contact.companyName);
|
|
484
|
+
if (!hasLinkedin && !hasNameAndCompany) {
|
|
485
|
+
throw new EnrichmentGatewayError(
|
|
486
|
+
"People enrichment requires linkedinUrl OR firstName+lastName+domain.",
|
|
487
|
+
{ code: "missing_people_identifier" },
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return contact;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function submitContactBulkChunk(
|
|
494
|
+
contacts: Array<{ inputIndex: number; payload: Record<string, unknown> }>,
|
|
495
|
+
options: EnrichmentGatewayOptions,
|
|
496
|
+
results: Map<number, BulkPersonResultEntry>,
|
|
497
|
+
): Promise<void> {
|
|
498
|
+
if (contacts.length === 0) return;
|
|
499
|
+
|
|
500
|
+
// Tag each contact with its original input index so cache hits and
|
|
501
|
+
// webhook results can be mapped back to the caller's row.
|
|
502
|
+
const requestContacts = contacts.map(({ inputIndex, payload }) => ({
|
|
503
|
+
...payload,
|
|
504
|
+
custom: { contactIndex: String(inputIndex) },
|
|
505
|
+
}));
|
|
506
|
+
|
|
507
|
+
const raw = await callGatewayJson("/v1/enrichment/person/contact", {
|
|
508
|
+
method: "POST",
|
|
509
|
+
body: { contacts: requestContacts, preferCache: true },
|
|
510
|
+
options,
|
|
511
|
+
expectedStatus: [200, 202],
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const resp = raw as {
|
|
515
|
+
enrichmentId?: string | null;
|
|
516
|
+
status?: string;
|
|
517
|
+
cachedResults?: Array<EnrichedPersonUpsertLike & { inputIndex?: number }>;
|
|
518
|
+
queuedCount?: number;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// Cache hits resolve immediately. The gateway tags each cached entry with
|
|
522
|
+
// `inputIndex` so we can place it in the right slot.
|
|
523
|
+
const cached = resp.cachedResults ?? [];
|
|
524
|
+
const seenIndexes = new Set<number>();
|
|
525
|
+
for (const hit of cached) {
|
|
526
|
+
const idx =
|
|
527
|
+
typeof hit.inputIndex === "number" ? hit.inputIndex : undefined;
|
|
528
|
+
if (idx === undefined) continue;
|
|
529
|
+
seenIndexes.add(idx);
|
|
530
|
+
results.set(idx, {
|
|
531
|
+
ok: true,
|
|
532
|
+
data: {
|
|
533
|
+
_meta: { cacheHit: true },
|
|
534
|
+
...shapePersonToApolloEnvelope(hit),
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// No queued misses → done.
|
|
540
|
+
const enrichmentId = resp.enrichmentId;
|
|
541
|
+
if (!enrichmentId) {
|
|
542
|
+
// Any remaining contacts in this chunk that didn't appear in cachedResults
|
|
543
|
+
// are unexpected — surface as not_found rather than hanging.
|
|
544
|
+
for (const { inputIndex } of contacts) {
|
|
545
|
+
if (seenIndexes.has(inputIndex) || results.has(inputIndex)) continue;
|
|
546
|
+
results.set(inputIndex, {
|
|
547
|
+
ok: false,
|
|
548
|
+
error: new EnrichmentGatewayError("No data returned", {
|
|
549
|
+
code: "not_found",
|
|
550
|
+
}),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Poll the job and demux results back to per-input-index slots.
|
|
557
|
+
await pollForBulkPersonResults(enrichmentId, contacts, results, options);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function pollForBulkPersonResults(
|
|
561
|
+
enrichmentId: string,
|
|
562
|
+
contacts: Array<{ inputIndex: number; payload: Record<string, unknown> }>,
|
|
563
|
+
results: Map<number, BulkPersonResultEntry>,
|
|
564
|
+
options: EnrichmentGatewayOptions,
|
|
565
|
+
): Promise<void> {
|
|
566
|
+
const pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
567
|
+
const pollTimeoutMs = options.pollTimeoutMs ?? 90000;
|
|
568
|
+
const deadline = Date.now() + pollTimeoutMs;
|
|
569
|
+
|
|
570
|
+
// Slots that this chunk is responsible for — anything still missing after
|
|
571
|
+
// the bulk job resolves is reported as `not_found` to the caller.
|
|
572
|
+
const ownedIndexes = contacts
|
|
573
|
+
.map(({ inputIndex }) => inputIndex)
|
|
574
|
+
.filter((idx) => !results.has(idx));
|
|
575
|
+
|
|
576
|
+
while (Date.now() < deadline) {
|
|
577
|
+
await sleep(pollIntervalMs, options.signal);
|
|
578
|
+
|
|
579
|
+
let raw: Record<string, unknown>;
|
|
580
|
+
try {
|
|
581
|
+
raw = await callGatewayJson(
|
|
582
|
+
`/v1/enrichment/jobs/${encodeURIComponent(enrichmentId)}`,
|
|
583
|
+
{ method: "GET", options },
|
|
584
|
+
);
|
|
585
|
+
} catch (err) {
|
|
586
|
+
if (
|
|
587
|
+
err instanceof EnrichmentGatewayError &&
|
|
588
|
+
(err.status === 404 || err.status === 503)
|
|
589
|
+
) {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
throw err;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const jobResp = raw as {
|
|
596
|
+
status?: string;
|
|
597
|
+
people?: Array<EnrichedPersonUpsertLike & { contactIndex?: number }>;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
if (jobResp.status === "succeeded") {
|
|
601
|
+
const people = jobResp.people ?? [];
|
|
602
|
+
for (const person of people) {
|
|
603
|
+
const idx =
|
|
604
|
+
typeof person.contactIndex === "number"
|
|
605
|
+
? person.contactIndex
|
|
606
|
+
: undefined;
|
|
607
|
+
if (idx === undefined) continue;
|
|
608
|
+
if (results.has(idx)) continue;
|
|
609
|
+
results.set(idx, {
|
|
610
|
+
ok: true,
|
|
611
|
+
data: {
|
|
612
|
+
_meta: { cacheHit: false, enrichmentId },
|
|
613
|
+
...shapePersonToApolloEnvelope(person),
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Anything FullEnrich didn't resolve is reported as not_found.
|
|
619
|
+
for (const idx of ownedIndexes) {
|
|
620
|
+
if (results.has(idx)) continue;
|
|
621
|
+
results.set(idx, {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: new EnrichmentGatewayError("No data returned", {
|
|
624
|
+
code: "not_found",
|
|
625
|
+
}),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (jobResp.status === "failed") {
|
|
632
|
+
for (const idx of ownedIndexes) {
|
|
633
|
+
if (results.has(idx)) continue;
|
|
634
|
+
results.set(idx, {
|
|
635
|
+
ok: false,
|
|
636
|
+
error: new EnrichmentGatewayError("No data returned", {
|
|
637
|
+
code: "not_found",
|
|
638
|
+
}),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
// pending — keep polling
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Timed out.
|
|
647
|
+
for (const idx of ownedIndexes) {
|
|
648
|
+
if (results.has(idx)) continue;
|
|
649
|
+
results.set(idx, {
|
|
650
|
+
ok: false,
|
|
651
|
+
error: new EnrichmentGatewayError("No data returned", {
|
|
652
|
+
code: "not_found",
|
|
653
|
+
}),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Bulk people enrichment via FullEnrich `/v1/enrichment/person/contact`.
|
|
660
|
+
*
|
|
661
|
+
* Submits a single request per chunk of up to 100 contacts and returns a map
|
|
662
|
+
* keyed by the caller's input index. Inputs that fail validation are recorded
|
|
663
|
+
* as per-row errors instead of throwing — callers can still emit results for
|
|
664
|
+
* the rows that did go through.
|
|
665
|
+
*/
|
|
666
|
+
export async function enrichPersonBulk(
|
|
667
|
+
inputs: Array<EnrichPersonBody | null | undefined>,
|
|
668
|
+
options: EnrichmentGatewayOptions = {},
|
|
669
|
+
): Promise<Map<number, BulkPersonResultEntry>> {
|
|
670
|
+
const results = new Map<number, BulkPersonResultEntry>();
|
|
671
|
+
const validContacts: Array<{
|
|
672
|
+
inputIndex: number;
|
|
673
|
+
payload: Record<string, unknown>;
|
|
674
|
+
}> = [];
|
|
675
|
+
|
|
676
|
+
inputs.forEach((body, index) => {
|
|
677
|
+
if (!body) {
|
|
678
|
+
results.set(index, {
|
|
679
|
+
ok: false,
|
|
680
|
+
error: new EnrichmentGatewayError("People enrichment input is empty.", {
|
|
681
|
+
code: "empty_identifier",
|
|
682
|
+
}),
|
|
683
|
+
});
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
validContacts.push({ inputIndex: index, payload: buildContactPayload(body) });
|
|
688
|
+
} catch (err) {
|
|
689
|
+
results.set(index, {
|
|
690
|
+
ok: false,
|
|
691
|
+
error:
|
|
692
|
+
err instanceof EnrichmentGatewayError
|
|
693
|
+
? err
|
|
694
|
+
: new EnrichmentGatewayError(
|
|
695
|
+
err instanceof Error ? err.message : "Invalid input",
|
|
696
|
+
{ code: "invalid_input" },
|
|
697
|
+
),
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
if (validContacts.length === 0) return results;
|
|
703
|
+
|
|
704
|
+
// Chunk and submit in parallel.
|
|
705
|
+
const chunks: Array<typeof validContacts> = [];
|
|
706
|
+
for (let i = 0; i < validContacts.length; i += FULLENRICH_BULK_CHUNK_SIZE) {
|
|
707
|
+
chunks.push(validContacts.slice(i, i + FULLENRICH_BULK_CHUNK_SIZE));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
await Promise.all(
|
|
711
|
+
chunks.map((chunk) => submitContactBulkChunk(chunk, options, results)),
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
return results;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function submitReverseEmailBulkChunk(
|
|
718
|
+
emails: Array<{ inputIndex: number; email: string }>,
|
|
719
|
+
options: EnrichmentGatewayOptions,
|
|
720
|
+
results: Map<number, BulkPersonResultEntry>,
|
|
721
|
+
): Promise<void> {
|
|
722
|
+
if (emails.length === 0) return;
|
|
723
|
+
|
|
724
|
+
// The gateway's reverse-email endpoint dedupes emails before calling
|
|
725
|
+
// FullEnrich, so multiple inputs with the same address end up sharing the
|
|
726
|
+
// same FullEnrich row. We still need to map each input index back to a
|
|
727
|
+
// result, so we keep an email→indexes map for fan-out.
|
|
728
|
+
const indexesByEmail = new Map<string, number[]>();
|
|
729
|
+
for (const { inputIndex, email } of emails) {
|
|
730
|
+
const arr = indexesByEmail.get(email) ?? [];
|
|
731
|
+
arr.push(inputIndex);
|
|
732
|
+
indexesByEmail.set(email, arr);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const raw = await callGatewayJson("/v1/enrichment/reverse/email", {
|
|
736
|
+
method: "POST",
|
|
737
|
+
body: {
|
|
738
|
+
emails: emails.map((e) => e.email),
|
|
739
|
+
preferCache: true,
|
|
740
|
+
},
|
|
741
|
+
options,
|
|
742
|
+
expectedStatus: [200, 202],
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const resp = raw as {
|
|
746
|
+
enrichmentId?: string | null;
|
|
747
|
+
status?: string;
|
|
748
|
+
cachedResults?: Array<EnrichedPersonUpsertLike & { inputIndex?: number }>;
|
|
749
|
+
queuedCount?: number;
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
const cached = resp.cachedResults ?? [];
|
|
753
|
+
for (const hit of cached) {
|
|
754
|
+
const idx =
|
|
755
|
+
typeof hit.inputIndex === "number" ? hit.inputIndex : undefined;
|
|
756
|
+
if (idx === undefined) continue;
|
|
757
|
+
// The gateway dedupes emails, so a cache hit at index i corresponds to
|
|
758
|
+
// the deduped email at that position in the request — we then fan out to
|
|
759
|
+
// every original input index that requested that email.
|
|
760
|
+
const requestEmail = emails[idx]?.email;
|
|
761
|
+
if (!requestEmail) continue;
|
|
762
|
+
const targets = indexesByEmail.get(requestEmail) ?? [idx];
|
|
763
|
+
for (const target of targets) {
|
|
764
|
+
results.set(target, {
|
|
765
|
+
ok: true,
|
|
766
|
+
data: {
|
|
767
|
+
_meta: { cacheHit: true },
|
|
768
|
+
...shapePersonToApolloEnvelope(hit),
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const enrichmentId = resp.enrichmentId;
|
|
775
|
+
if (!enrichmentId) {
|
|
776
|
+
for (const { inputIndex } of emails) {
|
|
777
|
+
if (results.has(inputIndex)) continue;
|
|
778
|
+
results.set(inputIndex, {
|
|
779
|
+
ok: false,
|
|
780
|
+
error: new EnrichmentGatewayError("No data returned", {
|
|
781
|
+
code: "not_found",
|
|
782
|
+
}),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Poll job and demux by `contactIndex` (gateway position, which maps to
|
|
789
|
+
// the deduped email list). Then fan out to every original input.
|
|
790
|
+
await pollForReverseEmailResults(
|
|
791
|
+
enrichmentId,
|
|
792
|
+
emails,
|
|
793
|
+
indexesByEmail,
|
|
794
|
+
results,
|
|
795
|
+
options,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function pollForReverseEmailResults(
|
|
800
|
+
enrichmentId: string,
|
|
801
|
+
emails: Array<{ inputIndex: number; email: string }>,
|
|
802
|
+
indexesByEmail: Map<string, number[]>,
|
|
803
|
+
results: Map<number, BulkPersonResultEntry>,
|
|
804
|
+
options: EnrichmentGatewayOptions,
|
|
805
|
+
): Promise<void> {
|
|
806
|
+
const pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
807
|
+
const pollTimeoutMs = options.pollTimeoutMs ?? 90000;
|
|
808
|
+
const deadline = Date.now() + pollTimeoutMs;
|
|
809
|
+
|
|
810
|
+
const ownedIndexes = emails
|
|
811
|
+
.map(({ inputIndex }) => inputIndex)
|
|
812
|
+
.filter((idx) => !results.has(idx));
|
|
813
|
+
|
|
814
|
+
while (Date.now() < deadline) {
|
|
815
|
+
await sleep(pollIntervalMs, options.signal);
|
|
816
|
+
|
|
817
|
+
let raw: Record<string, unknown>;
|
|
818
|
+
try {
|
|
819
|
+
raw = await callGatewayJson(
|
|
820
|
+
`/v1/enrichment/jobs/${encodeURIComponent(enrichmentId)}`,
|
|
821
|
+
{ method: "GET", options },
|
|
822
|
+
);
|
|
823
|
+
} catch (err) {
|
|
824
|
+
if (
|
|
825
|
+
err instanceof EnrichmentGatewayError &&
|
|
826
|
+
(err.status === 404 || err.status === 503)
|
|
827
|
+
) {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
throw err;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const jobResp = raw as {
|
|
834
|
+
status?: string;
|
|
835
|
+
people?: Array<EnrichedPersonUpsertLike & { contactIndex?: number }>;
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
if (jobResp.status === "succeeded") {
|
|
839
|
+
for (const person of jobResp.people ?? []) {
|
|
840
|
+
const idx =
|
|
841
|
+
typeof person.contactIndex === "number"
|
|
842
|
+
? person.contactIndex
|
|
843
|
+
: undefined;
|
|
844
|
+
if (idx === undefined) continue;
|
|
845
|
+
const requestEmail = emails[idx]?.email;
|
|
846
|
+
if (!requestEmail) continue;
|
|
847
|
+
const targets = indexesByEmail.get(requestEmail) ?? [];
|
|
848
|
+
for (const target of targets) {
|
|
849
|
+
if (results.has(target)) continue;
|
|
850
|
+
results.set(target, {
|
|
851
|
+
ok: true,
|
|
852
|
+
data: {
|
|
853
|
+
_meta: { cacheHit: false, enrichmentId },
|
|
854
|
+
...shapePersonToApolloEnvelope(person),
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
for (const idx of ownedIndexes) {
|
|
860
|
+
if (results.has(idx)) continue;
|
|
861
|
+
results.set(idx, {
|
|
862
|
+
ok: false,
|
|
863
|
+
error: new EnrichmentGatewayError("No data returned", {
|
|
864
|
+
code: "not_found",
|
|
865
|
+
}),
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (jobResp.status === "failed") {
|
|
872
|
+
for (const idx of ownedIndexes) {
|
|
873
|
+
if (results.has(idx)) continue;
|
|
874
|
+
results.set(idx, {
|
|
875
|
+
ok: false,
|
|
876
|
+
error: new EnrichmentGatewayError("No data returned", {
|
|
877
|
+
code: "not_found",
|
|
878
|
+
}),
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
for (const idx of ownedIndexes) {
|
|
886
|
+
if (results.has(idx)) continue;
|
|
887
|
+
results.set(idx, {
|
|
888
|
+
ok: false,
|
|
889
|
+
error: new EnrichmentGatewayError("No data returned", {
|
|
890
|
+
code: "not_found",
|
|
891
|
+
}),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Bulk reverse-email lookup via FullEnrich `/v1/enrichment/reverse/email`.
|
|
898
|
+
*
|
|
899
|
+
* Submits a single request per chunk of up to 100 emails and returns a map
|
|
900
|
+
* keyed by the caller's input index.
|
|
901
|
+
*/
|
|
902
|
+
export async function reverseEmailLookupBulk(
|
|
903
|
+
inputs: Array<string | null | undefined>,
|
|
904
|
+
options: EnrichmentGatewayOptions = {},
|
|
905
|
+
): Promise<Map<number, BulkPersonResultEntry>> {
|
|
906
|
+
const results = new Map<number, BulkPersonResultEntry>();
|
|
907
|
+
const validEmails: Array<{ inputIndex: number; email: string }> = [];
|
|
908
|
+
|
|
909
|
+
inputs.forEach((rawEmail, index) => {
|
|
910
|
+
const email = rawEmail?.trim().toLowerCase();
|
|
911
|
+
if (!email || !email.includes("@")) {
|
|
912
|
+
results.set(index, {
|
|
913
|
+
ok: false,
|
|
914
|
+
error: new EnrichmentGatewayError("Reverse email input is empty.", {
|
|
915
|
+
code: "empty_email",
|
|
916
|
+
}),
|
|
917
|
+
});
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
validEmails.push({ inputIndex: index, email });
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
if (validEmails.length === 0) return results;
|
|
924
|
+
|
|
925
|
+
const chunks: Array<typeof validEmails> = [];
|
|
926
|
+
for (let i = 0; i < validEmails.length; i += FULLENRICH_BULK_CHUNK_SIZE) {
|
|
927
|
+
chunks.push(validEmails.slice(i, i + FULLENRICH_BULK_CHUNK_SIZE));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
await Promise.all(
|
|
931
|
+
chunks.map((chunk) => submitReverseEmailBulkChunk(chunk, options, results)),
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
return results;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ---------------------------------------------------------------------------
|
|
938
|
+
// reverseEmailLookup — calls POST /v1/enrichment/reverse/email with poll
|
|
939
|
+
// ---------------------------------------------------------------------------
|
|
940
|
+
|
|
941
|
+
export async function reverseEmailLookup(
|
|
942
|
+
body: ReverseEmailBody,
|
|
943
|
+
options: EnrichmentGatewayOptions = {},
|
|
944
|
+
): Promise<Record<string, unknown>> {
|
|
945
|
+
const email = body.email.trim().toLowerCase();
|
|
946
|
+
if (!email || !email.includes("@")) {
|
|
947
|
+
throw new EnrichmentGatewayError("Reverse email input is empty.", {
|
|
948
|
+
code: "empty_email",
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const raw = await callGatewayJson("/v1/enrichment/reverse/email", {
|
|
953
|
+
method: "POST",
|
|
954
|
+
body: { emails: [email], preferCache: true },
|
|
955
|
+
options,
|
|
956
|
+
expectedStatus: [200, 202],
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
const resp = raw as {
|
|
960
|
+
enrichmentId?: string | null;
|
|
961
|
+
status?: string;
|
|
962
|
+
cachedResults?: unknown[];
|
|
963
|
+
queuedCount?: number;
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const cachedResults = resp.cachedResults ?? [];
|
|
967
|
+
if (cachedResults.length > 0) {
|
|
968
|
+
const hit = cachedResults[0] as EnrichedPersonUpsertLike;
|
|
969
|
+
return {
|
|
970
|
+
_meta: { cacheHit: true },
|
|
971
|
+
...shapePersonToApolloEnvelope(hit),
|
|
972
|
+
} as Record<string, unknown>;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const enrichmentId = resp.enrichmentId;
|
|
976
|
+
if (!enrichmentId) {
|
|
977
|
+
throw new EnrichmentGatewayError("No data returned", { code: "not_found" });
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return pollForPersonResult(enrichmentId, options);
|
|
146
981
|
}
|
|
147
982
|
|
|
983
|
+
// ---------------------------------------------------------------------------
|
|
984
|
+
// enrichCompany — calls POST /v1/enrichment/company/search (synchronous)
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
|
|
148
987
|
export async function enrichCompany(
|
|
149
988
|
body: EnrichCompanyBody,
|
|
150
989
|
options: EnrichmentGatewayOptions = {},
|
|
151
990
|
): Promise<Record<string, unknown>> {
|
|
152
|
-
const domain = extractDomain(body.domain);
|
|
153
|
-
|
|
991
|
+
const domain = body.domain ? extractDomain(body.domain) ?? body.domain : undefined;
|
|
992
|
+
const companyName = body.companyName?.trim();
|
|
993
|
+
const linkedinUrl = body.linkedinUrl?.trim();
|
|
994
|
+
|
|
995
|
+
if (!domain && !companyName && !linkedinUrl) {
|
|
154
996
|
throw new EnrichmentGatewayError("Company enrichment input is empty.", {
|
|
155
997
|
code: "empty_domain",
|
|
156
998
|
});
|
|
157
999
|
}
|
|
158
1000
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
1001
|
+
const requestBody: Record<string, unknown> = { limit: 1, preferCache: true };
|
|
1002
|
+
if (domain) requestBody.domain = domain;
|
|
1003
|
+
if (companyName) requestBody.name = companyName;
|
|
1004
|
+
if (linkedinUrl) requestBody.linkedinUrl = linkedinUrl;
|
|
1005
|
+
|
|
1006
|
+
const raw = await callGatewayJson("/v1/enrichment/company/search", {
|
|
1007
|
+
method: "POST",
|
|
1008
|
+
body: requestBody,
|
|
1009
|
+
options,
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
const resp = raw as { companies?: EnrichedCompanyUpsertLike[] };
|
|
1013
|
+
const companies = resp.companies ?? [];
|
|
1014
|
+
if (companies.length === 0) {
|
|
1015
|
+
throw new EnrichmentGatewayError("No data returned", { code: "not_found" });
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return shapeCompanyToApolloEnvelope(
|
|
1019
|
+
companies[0] as EnrichedCompanyUpsertLike,
|
|
1020
|
+
) as Record<string, unknown>;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ---------------------------------------------------------------------------
|
|
1024
|
+
// aviatoEnrichPerson — calls POST /v1/enrichment/aviato/person (synchronous)
|
|
1025
|
+
// ---------------------------------------------------------------------------
|
|
1026
|
+
|
|
1027
|
+
export type AviatoEnrichPersonBody = {
|
|
1028
|
+
linkedinUrl?: string;
|
|
1029
|
+
linkedinID?: string;
|
|
1030
|
+
linkedinNumID?: string;
|
|
1031
|
+
linkedinEntityId?: string;
|
|
1032
|
+
email?: string;
|
|
1033
|
+
preview?: boolean;
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
export type AviatoEnrichCompanyBody = {
|
|
1037
|
+
website?: string;
|
|
1038
|
+
linkedinUrl?: string;
|
|
1039
|
+
linkedinID?: string;
|
|
1040
|
+
linkedinNumID?: string;
|
|
1041
|
+
preview?: boolean;
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
export async function aviatoEnrichPerson(
|
|
1045
|
+
body: AviatoEnrichPersonBody,
|
|
1046
|
+
options: EnrichmentGatewayOptions = {},
|
|
1047
|
+
): Promise<Record<string, unknown>> {
|
|
1048
|
+
const hasIdentifier =
|
|
1049
|
+
body.linkedinUrl ||
|
|
1050
|
+
body.linkedinID ||
|
|
1051
|
+
body.linkedinNumID ||
|
|
1052
|
+
body.linkedinEntityId ||
|
|
1053
|
+
body.email;
|
|
1054
|
+
if (!hasIdentifier) {
|
|
1055
|
+
throw new EnrichmentGatewayError(
|
|
1056
|
+
"Aviato person enrichment requires at least one identifier: linkedinUrl, linkedinID, linkedinNumID, linkedinEntityId, or email.",
|
|
1057
|
+
{ code: "empty_identifier" },
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const requestBody: Record<string, unknown> = {};
|
|
1062
|
+
if (body.linkedinUrl) requestBody.linkedinURL = body.linkedinUrl.trim();
|
|
1063
|
+
if (body.linkedinID) requestBody.linkedinID = body.linkedinID.trim();
|
|
1064
|
+
if (body.linkedinNumID) requestBody.linkedinNumID = body.linkedinNumID.trim();
|
|
1065
|
+
if (body.linkedinEntityId)
|
|
1066
|
+
requestBody.linkedinEntityId = body.linkedinEntityId.trim();
|
|
1067
|
+
if (body.email) requestBody.email = body.email.trim();
|
|
1068
|
+
if (body.preview !== undefined) requestBody.preview = body.preview;
|
|
1069
|
+
|
|
1070
|
+
const raw = await callGatewayJson("/v1/enrichment/aviato/person", {
|
|
1071
|
+
method: "POST",
|
|
1072
|
+
body: requestBody,
|
|
1073
|
+
options,
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const resp = raw as {
|
|
1077
|
+
data?: EnrichedPersonUpsertLike | null;
|
|
1078
|
+
billing?: { charged_cents?: number; cache_hit?: boolean; preview?: boolean };
|
|
1079
|
+
provider?: { name?: string; aviato_id?: string };
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
if (!resp.data) {
|
|
1083
|
+
throw new EnrichmentGatewayError("No data returned", { code: "not_found" });
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return {
|
|
1087
|
+
_meta: {
|
|
1088
|
+
provider: "aviato",
|
|
1089
|
+
cacheHit: resp.billing?.cache_hit ?? false,
|
|
1090
|
+
chargedCents: resp.billing?.charged_cents ?? 0,
|
|
1091
|
+
},
|
|
1092
|
+
...shapePersonToApolloEnvelope(resp.data),
|
|
1093
|
+
} as Record<string, unknown>;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// ---------------------------------------------------------------------------
|
|
1097
|
+
// aviatoEnrichCompany — calls POST /v1/enrichment/aviato/company (synchronous)
|
|
1098
|
+
// ---------------------------------------------------------------------------
|
|
1099
|
+
|
|
1100
|
+
export async function aviatoEnrichCompany(
|
|
1101
|
+
body: AviatoEnrichCompanyBody,
|
|
1102
|
+
options: EnrichmentGatewayOptions = {},
|
|
1103
|
+
): Promise<Record<string, unknown>> {
|
|
1104
|
+
const hasIdentifier =
|
|
1105
|
+
body.website || body.linkedinUrl || body.linkedinID || body.linkedinNumID;
|
|
1106
|
+
if (!hasIdentifier) {
|
|
1107
|
+
throw new EnrichmentGatewayError(
|
|
1108
|
+
"Aviato company enrichment requires at least one identifier: website, linkedinUrl, linkedinID, or linkedinNumID.",
|
|
1109
|
+
{ code: "empty_identifier" },
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const requestBody: Record<string, unknown> = {};
|
|
1114
|
+
if (body.website) requestBody.website = body.website.trim();
|
|
1115
|
+
if (body.linkedinUrl) requestBody.linkedinURL = body.linkedinUrl.trim();
|
|
1116
|
+
if (body.linkedinID) requestBody.linkedinID = body.linkedinID.trim();
|
|
1117
|
+
if (body.linkedinNumID) requestBody.linkedinNumID = body.linkedinNumID.trim();
|
|
1118
|
+
if (body.preview !== undefined) requestBody.preview = body.preview;
|
|
1119
|
+
|
|
1120
|
+
const raw = await callGatewayJson("/v1/enrichment/aviato/company", {
|
|
1121
|
+
method: "POST",
|
|
1122
|
+
body: requestBody,
|
|
1123
|
+
options,
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
const resp = raw as {
|
|
1127
|
+
data?: EnrichedCompanyUpsertLike | null;
|
|
1128
|
+
billing?: { charged_cents?: number; cache_hit?: boolean; preview?: boolean };
|
|
1129
|
+
provider?: { name?: string; aviato_id?: string };
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
if (!resp.data) {
|
|
1133
|
+
throw new EnrichmentGatewayError("No data returned", { code: "not_found" });
|
|
165
1134
|
}
|
|
166
1135
|
|
|
167
|
-
return
|
|
1136
|
+
return {
|
|
1137
|
+
_meta: {
|
|
1138
|
+
provider: "aviato",
|
|
1139
|
+
cacheHit: resp.billing?.cache_hit ?? false,
|
|
1140
|
+
chargedCents: resp.billing?.charged_cents ?? 0,
|
|
1141
|
+
},
|
|
1142
|
+
...shapeCompanyToApolloEnvelope(resp.data),
|
|
1143
|
+
} as Record<string, unknown>;
|
|
168
1144
|
}
|
|
169
1145
|
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
// searchPeople — calls POST /v1/enrichment/people/search (synchronous)
|
|
1148
|
+
// ---------------------------------------------------------------------------
|
|
1149
|
+
|
|
170
1150
|
export async function searchPeople(
|
|
171
1151
|
body: SearchPeopleBody,
|
|
172
1152
|
options: EnrichmentGatewayOptions = {},
|
|
173
1153
|
): Promise<Record<string, unknown>> {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
1154
|
+
// Request body matches gateway `peopleSearchRequestSchema`:
|
|
1155
|
+
// `locations`, `headcountRanges`, `titles`, `companyDomain`, etc.
|
|
1156
|
+
const payload: Record<string, unknown> = { preferCache: true };
|
|
1157
|
+
|
|
1158
|
+
if (body.personTitles?.length) payload.titles = body.personTitles;
|
|
1159
|
+
|
|
1160
|
+
const locations = [
|
|
1161
|
+
...(body.personLocations ?? []),
|
|
1162
|
+
...(body.organizationLocations ?? []),
|
|
1163
|
+
]
|
|
1164
|
+
.map((s) => s.trim())
|
|
1165
|
+
.filter(Boolean);
|
|
1166
|
+
if (locations.length > 0) payload.locations = locations;
|
|
1167
|
+
|
|
1168
|
+
if (body.organizationDomains && body.organizationDomains.length > 0) {
|
|
1169
|
+
payload.companyDomain = body.organizationDomains[0];
|
|
182
1170
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
1171
|
+
|
|
1172
|
+
const headcountRanges = (body.organizationNumEmployeesRanges ?? [])
|
|
1173
|
+
.map((s) => s.trim())
|
|
1174
|
+
.filter(Boolean);
|
|
1175
|
+
if (headcountRanges.length > 0) {
|
|
1176
|
+
payload.headcountRanges = headcountRanges;
|
|
186
1177
|
}
|
|
187
|
-
|
|
188
|
-
if (body.
|
|
189
|
-
if (body.perPage !== undefined) payload.
|
|
1178
|
+
|
|
1179
|
+
if (body.qKeywords) payload.keywords = body.qKeywords;
|
|
1180
|
+
if (body.perPage !== undefined) payload.limit = body.perPage;
|
|
190
1181
|
|
|
191
1182
|
return callGatewayJson("/v1/enrichment/people/search", {
|
|
192
1183
|
method: "POST",
|
|
@@ -195,16 +1186,23 @@ export async function searchPeople(
|
|
|
195
1186
|
});
|
|
196
1187
|
}
|
|
197
1188
|
|
|
1189
|
+
// ---------------------------------------------------------------------------
|
|
1190
|
+
// Shared HTTP helpers
|
|
1191
|
+
// ---------------------------------------------------------------------------
|
|
1192
|
+
|
|
198
1193
|
async function callGatewayJson(
|
|
199
1194
|
pathOrUrl: string | URL,
|
|
200
1195
|
args: {
|
|
201
1196
|
method: "GET" | "POST";
|
|
202
1197
|
body?: Record<string, unknown>;
|
|
203
1198
|
options: EnrichmentGatewayOptions;
|
|
1199
|
+
/** HTTP statuses considered "ok" beyond the default 2xx check. */
|
|
1200
|
+
expectedStatus?: number[];
|
|
204
1201
|
},
|
|
205
1202
|
): Promise<Record<string, unknown>> {
|
|
206
1203
|
const fetchImpl = args.options.fetchImpl ?? fetch;
|
|
207
|
-
const apiKey =
|
|
1204
|
+
const apiKey =
|
|
1205
|
+
args.options.apiKey ?? requireEnrichmentApiKey(args.options.env);
|
|
208
1206
|
const url =
|
|
209
1207
|
typeof pathOrUrl === "string"
|
|
210
1208
|
? `${resolveBaseUrl(args.options)}${pathOrUrl}`
|
|
@@ -217,9 +1215,14 @@ async function callGatewayJson(
|
|
|
217
1215
|
Authorization: `Bearer ${apiKey}`,
|
|
218
1216
|
},
|
|
219
1217
|
...(args.body ? { body: JSON.stringify(args.body) } : {}),
|
|
1218
|
+
...(args.options.signal ? { signal: args.options.signal } : {}),
|
|
220
1219
|
});
|
|
221
1220
|
|
|
222
|
-
|
|
1221
|
+
const isOk =
|
|
1222
|
+
response.ok ||
|
|
1223
|
+
(args.expectedStatus?.includes(response.status) ?? false);
|
|
1224
|
+
|
|
1225
|
+
if (!isOk) {
|
|
223
1226
|
throw await buildGatewayError(response, pathOrUrl.toString());
|
|
224
1227
|
}
|
|
225
1228
|
|
|
@@ -280,3 +1283,21 @@ async function buildGatewayError(
|
|
|
280
1283
|
{ status: response.status, code },
|
|
281
1284
|
);
|
|
282
1285
|
}
|
|
1286
|
+
|
|
1287
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1288
|
+
return new Promise((resolve, reject) => {
|
|
1289
|
+
if (signal?.aborted) {
|
|
1290
|
+
reject(new EnrichmentGatewayError("aborted", { code: "aborted" }));
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const timer = setTimeout(() => {
|
|
1294
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1295
|
+
resolve();
|
|
1296
|
+
}, ms);
|
|
1297
|
+
const onAbort = () => {
|
|
1298
|
+
clearTimeout(timer);
|
|
1299
|
+
reject(new EnrichmentGatewayError("aborted", { code: "aborted" }));
|
|
1300
|
+
};
|
|
1301
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1302
|
+
});
|
|
1303
|
+
}
|