@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.
@@ -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,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 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) {
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 at least one identifier.",
331
+ "People enrichment requires linkedinUrl OR firstName+lastName+domain.",
129
332
  { code: "missing_people_identifier" },
130
333
  );
131
334
  }
132
335
 
133
- return callGatewayJson("/v1/enrichment/people", {
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: payload,
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(buildPersonBodyFromIdentifier(identifier, requiredFields), options);
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 = extractDomain(body.domain);
153
- if (!domain) {
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 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(","));
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 callGatewayJson(url, { method: "GET", options });
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
- 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;
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
- if (body.organizationNumEmployeesRanges) {
184
- payload.organization_num_employees_ranges =
185
- body.organizationNumEmployeesRanges;
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
- 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;
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 = args.options.apiKey ?? requireEnrichmentApiKey(args.options.env);
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
- if (!response.ok) {
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
+ }