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