@dench.com/cli 0.4.7 → 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.
@@ -36,10 +36,16 @@ export type EnrichPersonBody = {
36
36
  };
37
37
 
38
38
  export type EnrichCompanyBody = {
39
- domain: string;
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 payload: Record<string, unknown> = {};
116
- if (body.email) payload.email = body.email;
117
- if (body.linkedinUrl) payload.linkedin_url = body.linkedinUrl;
118
- if (body.firstName) payload.first_name = body.firstName;
119
- if (body.lastName) payload.last_name = body.lastName;
120
- if (body.domain) payload.domain = body.domain;
121
- if (body.organizationName) payload.organization_name = body.organizationName;
122
- if (body.requiredFields && body.requiredFields.length > 0) {
123
- payload.requiredFields = body.requiredFields;
124
- }
125
-
126
- if (Object.keys(payload).length === 0) {
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 at least one identifier.",
324
+ "People enrichment requires linkedinUrl OR firstName+lastName+domain.",
129
325
  { code: "missing_people_identifier" },
130
326
  );
131
327
  }
132
328
 
133
- return callGatewayJson("/v1/enrichment/people", {
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: payload,
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(buildPersonBodyFromIdentifier(identifier, requiredFields), options);
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
- if (!domain) {
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 url = new URL(
160
- `${resolveBaseUrl(options)}/v1/enrichment/company`,
161
- );
162
- url.searchParams.set("domain", domain);
163
- if (body.requiredFields && body.requiredFields.length > 0) {
164
- url.searchParams.set("requiredFields", body.requiredFields.join(","));
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 callGatewayJson(url, { method: "GET", options });
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
- const payload: Record<string, unknown> = {};
175
- if (body.personTitles) payload.person_titles = body.personTitles;
176
- if (body.personLocations) payload.person_locations = body.personLocations;
177
- if (body.organizationDomains) {
178
- payload.organization_domains = body.organizationDomains;
179
- }
180
- if (body.organizationLocations) {
181
- payload.organization_locations = body.organizationLocations;
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
- if (body.organizationNumEmployeesRanges) {
184
- payload.organization_num_employees_ranges =
185
- body.organizationNumEmployeesRanges;
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
- if (body.qKeywords) payload.q_keywords = body.qKeywords;
188
- if (body.page !== undefined) payload.page = body.page;
189
- if (body.perPage !== undefined) payload.per_page = body.perPage;
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 = args.options.apiKey ?? requireEnrichmentApiKey(args.options.env);
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
- if (!response.ok) {
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
+ }