import { type EnrichedCompanyUpsertLike, type EnrichedPersonUpsertLike, shapeCompanyToApolloEnvelope, shapePersonToApolloEnvelope, } from "./enrichment-shapes"; export type { ApolloCompanyEnvelope, ApolloPersonEnvelope, } from "./enrichment-shapes"; import { extractDomain } from "./crm-enrichment"; import { normalizeGatewayBaseUrl } from "./gateway-url"; export type FetchLike = ( input: string | URL, init?: RequestInit, ) => Promise; export type EnrichmentGatewayEnv = { DENCH_API_KEY?: string; DENCH_GATEWAY_URL?: string; GATEWAY_URL?: string; DENCH_HOST?: string; [key: string]: string | undefined; }; export class EnrichmentGatewayError extends Error { readonly status?: number; readonly code?: string; constructor(message: string, options?: { status?: number; code?: string }) { super(message); this.name = "EnrichmentGatewayError"; this.status = options?.status; this.code = options?.code; } } /** These specific 503 responses are emitted by the gateway billing gate * before provider execution. Other 5xx responses may have purchased a page. */ export function enrichmentBillingPreflightMessage(error: { status?: number; code?: string; }): string | undefined { if (error.status !== 503) return undefined; if (error.code === "billing_state_unavailable") { return "AI billing state is temporarily unavailable. Please retry."; } if (error.code === "billing_state_busy") { return "AI billing state is temporarily busy. Please retry shortly."; } return undefined; } export type EnrichPersonBody = { email?: string; linkedinUrl?: string; firstName?: string; lastName?: string; domain?: string; organizationName?: string; requiredFields?: string[]; }; export type EnrichCompanyBody = { domain?: string; companyName?: string; linkedinUrl?: string; requiredFields?: string[]; }; export type ReverseEmailBody = { email: string; }; /** One list-filter entry: plain include string or `{value, exclude, exactMatch}`. */ export type SearchFilterItem = | string | { value: string; exclude?: boolean; exactMatch?: boolean }; export type SearchRangeFilter = { min?: number; max?: number }; export type SearchBilling = { estimatedCents: number; chargedCents: number; }; export type SearchResponse = Record & { billing?: SearchBilling; }; export type SearchPeopleBody = { /** Canonical gateway fields. locations is the PERSON; companyHeadquarters is the employer. */ linkedinUrl?: string; fullName?: string; firstName?: string; lastName?: string; companyDomain?: string; companyName?: string; exactMatch?: boolean; preferCache?: boolean; personTitles?: string[]; personLocations?: string[]; personSeniorities?: string[]; personFunctions?: string[]; organizationDomains?: string[]; organizationLocations?: string[]; organizationIndustries?: string[]; organizationNumEmployeesRanges?: string[]; qKeywords?: string; keywords?: string; page?: number; perPage?: number; limit?: number; offset?: number; searchAfter?: unknown; /** Opaque token from a previous response's `metadata.nextPageToken`. */ pageToken?: string; // Rich filters, gateway-named. Merged with the legacy person*/organization* // aliases above; entries may carry per-value exclude / exactMatch. titles?: SearchFilterItem[]; pastTitles?: SearchFilterItem[]; personNames?: SearchFilterItem[]; seniorities?: SearchFilterItem[]; pastSeniorities?: SearchFilterItem[]; profileKeywords?: SearchFilterItem[]; companyKeywords?: SearchFilterItem[]; jobFunctions?: SearchFilterItem[]; subFunctions?: SearchFilterItem[]; industries?: SearchFilterItem[]; locations?: SearchFilterItem[]; headcountRanges?: SearchFilterItem[]; companyNames?: SearchFilterItem[]; pastCompanyNames?: SearchFilterItem[]; companyDomains?: SearchFilterItem[]; companyScope?: { domains?: string[]; names?: string[] }; companyHeadquarters?: SearchFilterItem[]; companyTypes?: SearchFilterItem[]; universities?: SearchFilterItem[]; skills?: SearchFilterItem[]; languages?: SearchFilterItem[]; yearsInRole?: SearchRangeFilter; yearsAtCompany?: SearchRangeFilter; daysSinceJobChange?: SearchRangeFilter; foundedYears?: SearchRangeFilter; }; export type SearchCompaniesBody = { exactMatch?: boolean; preferCache?: boolean; domain?: string; name?: string; linkedinUrl?: string; industries?: string[]; locations?: string[]; headcountRanges?: string[]; keywords?: string; limit?: number; offset?: number; /** Opaque token from a previous response's `metadata.nextPageToken`. */ pageToken?: string; }; export type SearchJobsBody = { titles?: string[]; locations?: string[]; companyDomain?: string; /** Multiple company domains (e.g. a resolved company list); the gateway * fans lists past ~50 domains out as sequential vendor-safe chunks. */ companyDomains?: string[]; companyName?: string; companyNames?: string[]; companyLinkedinUrl?: string; keywords?: string; postedAfter?: string; postedBefore?: string; limit?: number; /** Opaque token from a previous response's `metadata.nextPageToken`. */ pageToken?: string; }; export type SignalKind = | "post_engagement" | "keyword" | "hiring" | "funding" | "launch" | "new_leader"; export type SearchSignalsBody = { signal: SignalKind; titles?: string[]; market?: string; locations?: string[]; headcountRanges?: string[]; excludeDomains?: string[]; excludeCompanies?: string[]; includeCompanies?: string[]; /** Job change only: the companies' industries. */ industries?: string[]; postedAfter: string; limit?: number; terms?: string[]; recency?: "week" | "month"; mode?: "search" | "volume"; }; export type SignalRow = { signal: SignalKind; companyName: string; companyDomain: string; companyLinkedinUrl?: string; headline: string; occurredAt?: string; detail: { jobTitles?: string[]; jobCount?: number; jobUrl?: string; role?: string; roundType?: string; amount?: string; product?: string; sourceUrl?: string; personName?: string; personTitle?: string; personLinkedinUrl?: string; personAvatarUrl?: string; term?: string; postUrl?: string; postText?: string; engagement?: "reaction" | "comment" | "post"; }; }; export type SearchSignalsResponse = { signals: SignalRow[]; creditsCharged: number; volume?: { term: string; count: number; saturated?: true }[]; metadata?: { warnings?: string[]; checked?: number; misses?: { title: number; location: number; company: number }; }; }; /** * FullEnrich hard cases run 40–120s end to end; a 90s budget produced * spurious "No data returned" failures for jobs that later completed (and * charged). 150s covers the observed tail while the 2s interval keeps * fast results snappy. */ const DEFAULT_POLL_TIMEOUT_MS = 150000; export type EnrichmentGatewayOptions = { apiKey?: string; baseUrl?: string; env?: EnrichmentGatewayEnv; fetchImpl?: FetchLike; /** Override the poll interval (ms) for testing. Default: 2000. */ pollIntervalMs?: number; /** Override the poll timeout (ms) for testing. Default: 150000. */ pollTimeoutMs?: number; /** * Optional abort signal — propagated to fetch and short-circuits the polling * loop so callers (e.g. SSE clients that disconnect) can stop in-flight work. */ signal?: AbortSignal; }; // --------------------------------------------------------------------------- // URL / key resolution (unchanged for backward compat) // --------------------------------------------------------------------------- export function resolveEnrichmentGatewayBaseUrl( env: EnrichmentGatewayEnv = process.env, ): string { const explicit = env.DENCH_GATEWAY_URL?.trim() || env.GATEWAY_URL?.trim(); if (explicit) return normalizeGatewayBaseUrl(explicit); const host = env.DENCH_HOST?.trim(); if (host && /localhost|127\.0\.0\.1/.test(host)) { return "http://localhost:8787"; } return "https://gateway.merseoriginals.com"; } export function requireEnrichmentApiKey( env: EnrichmentGatewayEnv = process.env, ): string { const apiKey = env.DENCH_API_KEY?.trim(); if (!apiKey) { throw new EnrichmentGatewayError( "DENCH_API_KEY is not set. Inside a Dench sandbox it is baked into " + "the env automatically; outside, export it manually or run " + "`dench login` first.", { code: "missing_api_key" }, ); } return apiKey; } export function buildPersonBodyFromIdentifier( identifier: string, requiredFields?: string[], ): EnrichPersonBody { const trimmed = identifier.trim(); if (!trimmed) { throw new EnrichmentGatewayError("People enrichment input is empty.", { code: "empty_identifier", }); } if (/linkedin\.com/i.test(trimmed)) { return { linkedinUrl: trimmed, requiredFields }; } if (trimmed.includes("@")) { return { email: trimmed, requiredFields }; } throw new EnrichmentGatewayError( "Unsupported people identifier. Use an email address or LinkedIn URL.", { code: "unsupported_people_identifier" }, ); } // --------------------------------------------------------------------------- // requiredFields → enrichFields translation // --------------------------------------------------------------------------- type EnrichField = "work_emails" | "personal_emails" | "phones"; function mapRequiredFieldsToEnrichFields(rf?: string[]): EnrichField[] { const set = new Set(); for (const f of rf ?? []) { if (f === "phone") set.add("phones"); else if (f === "email" || f === "work_email") set.add("work_emails"); else if (f === "personal_email") set.add("personal_emails"); // profile-only fields (fullName, linkedinID, headline, URLs, location, etc.) → ignored } // If no contact-data fields requested, default to work_emails so FE // returns at minimum profile + cheapest contact charge. return set.size > 0 ? [...set] : ["work_emails"]; } // --------------------------------------------------------------------------- // Apollo-shape envelope (preserves crm-enrichment catalog extraction paths) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // enrichPerson — calls POST /v1/enrichment/person/contact with block-and-poll // --------------------------------------------------------------------------- export async function enrichPerson( body: EnrichPersonBody, options: EnrichmentGatewayOptions = {}, ): Promise> { const enrichFields = mapRequiredFieldsToEnrichFields(body.requiredFields); // Build the contact payload for the new endpoint. const contactPayload: Record = {}; if (body.linkedinUrl) contactPayload.linkedinUrl = body.linkedinUrl; if (body.firstName) contactPayload.firstName = body.firstName; if (body.lastName) contactPayload.lastName = body.lastName; if (body.domain) { contactPayload.domain = extractDomain(body.domain) ?? body.domain; } else if (body.email) { // Derive domain from email as a fallback input for person/contact. const emailDomain = body.email.split("@")[1]?.trim(); if (emailDomain) contactPayload.domain = emailDomain; } if (body.organizationName) contactPayload.companyName = body.organizationName; contactPayload.enrichFields = enrichFields; // Validate we have enough to call person/contact. const hasLinkedin = Boolean(contactPayload.linkedinUrl); const hasNameAndCompany = Boolean(contactPayload.firstName) && Boolean(contactPayload.lastName) && Boolean(contactPayload.domain || contactPayload.companyName); if (!hasLinkedin && !hasNameAndCompany) { throw new EnrichmentGatewayError( "People enrichment requires linkedinUrl OR firstName+lastName+domain.", { code: "missing_people_identifier" }, ); } const requestBody = { contacts: [contactPayload], preferCache: true, }; const raw = await callGatewayJson("/v1/enrichment/person/contact", { method: "POST", body: requestBody, options, expectedStatus: [200, 202], }); const personContactResp = raw as { enrichmentId?: string | null; status?: string; cachedResults?: unknown[]; queuedCount?: number; }; // Cache hit: shape and return immediately. const cachedResults = personContactResp.cachedResults ?? []; if (cachedResults.length > 0) { const hit = cachedResults[0] as EnrichedPersonUpsertLike & { error?: { message: string; code: string }; source?: string; warnings?: string[]; }; if (hit.error) throw new EnrichmentGatewayError(hit.error.message, { code: hit.error.code, }); return { _meta: { cacheHit: hit.source !== "live", ...(hit.warnings?.length ? { warnings: hit.warnings } : {}), }, ...shapePersonToApolloEnvelope(hit), } as Record; } // Queued: poll for the result. const enrichmentId = personContactResp.enrichmentId; if (!enrichmentId) { throw new EnrichmentGatewayError("No data returned", { code: "not_found", }); } return pollForPersonResult(enrichmentId, options); } async function pollForPersonResult( enrichmentId: string, options: EnrichmentGatewayOptions, ): Promise> { const pollIntervalMs = options.pollIntervalMs ?? 2000; const pollTimeoutMs = options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; const deadline = Date.now() + pollTimeoutMs; while (Date.now() < deadline) { await sleep(pollIntervalMs, options.signal); let raw: Record; try { raw = await callGatewayJson( `/v1/enrichment/jobs/${encodeURIComponent(enrichmentId)}`, { method: "GET", options }, ); } catch (err) { // 404 means the webhook hasn't arrived and been persisted yet — treat as // "still pending" and keep polling until the deadline. // 503 means the gateway is temporarily unavailable — same treatment. // Any other error (auth failure, malformed request, etc.) is fatal. if ( err instanceof EnrichmentGatewayError && (err.status === 404 || err.status === 503) ) { continue; } throw err; } const jobResp = raw as { status?: string; people?: EnrichedPersonUpsertLike[]; }; if (jobResp.status === "succeeded") { const people = jobResp.people ?? []; if (people.length === 0) { throw new EnrichmentGatewayError("No data returned", { code: "not_found", }); } const person = people[0] as EnrichedPersonUpsertLike; return { _meta: { cacheHit: false, enrichmentId }, ...shapePersonToApolloEnvelope(person), } as Record; } if (jobResp.status === "failed") { throw new EnrichmentGatewayError("No data returned", { code: "not_found", }); } // status === "pending" — keep polling } // Timed out. throw new EnrichmentGatewayError("No data returned", { code: "not_found" }); } export async function enrichPersonByIdentifier( identifier: string, requiredFields?: string[], options: EnrichmentGatewayOptions = {}, ): Promise> { return enrichPerson( buildPersonBodyFromIdentifier(identifier, requiredFields), options, ); } // --------------------------------------------------------------------------- // Bulk helpers — submit one gateway request per chunk, poll once per job, // and return a per-input-index result map. Used by the streaming enrich // route to avoid waiting 30–90s per row. // --------------------------------------------------------------------------- /** FullEnrich's hard cap on contacts per bulk request. */ const FULLENRICH_BULK_CHUNK_SIZE = 100; export type BulkPersonResultEntry = | { ok: true; data: Record } | { ok: false; error: EnrichmentGatewayError }; /** * Build a contact payload identical to what `enrichPerson` sends, but without * actually firing the request. Used by the bulk path so the route can validate * inputs row-by-row before submitting a single bulk request. */ export function buildContactPayload( body: EnrichPersonBody, ): Record { const enrichFields = mapRequiredFieldsToEnrichFields(body.requiredFields); const contact: Record = { enrichFields }; if (body.linkedinUrl) contact.linkedinUrl = body.linkedinUrl; if (body.firstName) contact.firstName = body.firstName; if (body.lastName) contact.lastName = body.lastName; if (body.domain) { contact.domain = extractDomain(body.domain) ?? body.domain; } else if (body.email) { const emailDomain = body.email.split("@")[1]?.trim(); if (emailDomain) contact.domain = emailDomain; } if (body.organizationName) contact.companyName = body.organizationName; const hasLinkedin = Boolean(contact.linkedinUrl); const hasNameAndCompany = Boolean(contact.firstName) && Boolean(contact.lastName) && Boolean(contact.domain || contact.companyName); if (!hasLinkedin && !hasNameAndCompany) { throw new EnrichmentGatewayError( "People enrichment requires linkedinUrl OR firstName+lastName+domain.", { code: "missing_people_identifier" }, ); } return contact; } async function submitContactBulkChunk( contacts: Array<{ inputIndex: number; payload: Record }>, options: EnrichmentGatewayOptions, results: Map, ): Promise { if (contacts.length === 0) return; // Tag each contact with its original input index so cache hits and // webhook results can be mapped back to the caller's row. const requestContacts = contacts.map(({ inputIndex, payload }) => ({ ...payload, custom: { contactIndex: String(inputIndex) }, })); const raw = await callGatewayJson("/v1/enrichment/person/contact", { method: "POST", body: { contacts: requestContacts, preferCache: true }, options, expectedStatus: [200, 202], }); const resp = raw as { enrichmentId?: string | null; status?: string; cachedResults?: Array< EnrichedPersonUpsertLike & { inputIndex?: number; source?: string; error?: { message: string; code: string }; warnings?: string[]; } >; queuedCount?: number; }; // Cache hits resolve immediately. The gateway tags each cached entry with // `inputIndex` so we can place it in the right slot. const cached = resp.cachedResults ?? []; const seenIndexes = new Set(); for (const hit of cached) { // Gateway inputIndex is chunk-local; callers may have global indexes. const idx = typeof hit.inputIndex === "number" ? contacts[hit.inputIndex]?.inputIndex : undefined; if (idx === undefined) continue; seenIndexes.add(idx); if (hit.error) { results.set(idx, { ok: false, error: new EnrichmentGatewayError(hit.error.message, { code: hit.error.code, }), }); continue; } results.set(idx, { ok: true, data: { _meta: { cacheHit: hit.source !== "live", ...(hit.warnings?.length ? { warnings: hit.warnings } : {}), }, ...shapePersonToApolloEnvelope(hit), }, }); } // No queued misses → done. const enrichmentId = resp.enrichmentId; if (!enrichmentId) { // Any remaining contacts in this chunk that didn't appear in cachedResults // are unexpected — surface as not_found rather than hanging. for (const { inputIndex } of contacts) { if (seenIndexes.has(inputIndex) || results.has(inputIndex)) continue; results.set(inputIndex, { ok: false, error: new EnrichmentGatewayError("No data returned", { code: "not_found", }), }); } return; } // Poll the job and demux results back to per-input-index slots. await pollForBulkPersonResults(enrichmentId, contacts, results, options); } async function pollForBulkPersonResults( enrichmentId: string, contacts: Array<{ inputIndex: number; payload: Record }>, results: Map, options: EnrichmentGatewayOptions, ): Promise { const pollIntervalMs = options.pollIntervalMs ?? 2000; const pollTimeoutMs = options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; const deadline = Date.now() + pollTimeoutMs; // Slots that this chunk is responsible for — anything still missing after // the bulk job resolves is reported as `not_found` to the caller. const ownedIndexes = contacts .map(({ inputIndex }) => inputIndex) .filter((idx) => !results.has(idx)); while (Date.now() < deadline) { await sleep(pollIntervalMs, options.signal); let raw: Record; try { raw = await callGatewayJson( `/v1/enrichment/jobs/${encodeURIComponent(enrichmentId)}`, { method: "GET", options }, ); } catch (err) { if ( err instanceof EnrichmentGatewayError && (err.status === 404 || err.status === 503) ) { continue; } throw err; } const jobResp = raw as { status?: string; people?: Array; }; if (jobResp.status === "succeeded") { const people = jobResp.people ?? []; for (const person of people) { const idx = typeof person.contactIndex === "number" ? person.contactIndex : undefined; if (idx === undefined) continue; if (results.has(idx)) continue; results.set(idx, { ok: true, data: { _meta: { cacheHit: false, enrichmentId }, ...shapePersonToApolloEnvelope(person), }, }); } // Anything FullEnrich didn't resolve is reported as not_found. for (const idx of ownedIndexes) { if (results.has(idx)) continue; results.set(idx, { ok: false, error: new EnrichmentGatewayError("No data returned", { code: "not_found", }), }); } return; } if (jobResp.status === "failed") { for (const idx of ownedIndexes) { if (results.has(idx)) continue; results.set(idx, { ok: false, error: new EnrichmentGatewayError("No data returned", { code: "not_found", }), }); } return; } // pending — keep polling } // Timed out. for (const idx of ownedIndexes) { if (results.has(idx)) continue; results.set(idx, { ok: false, error: new EnrichmentGatewayError("No data returned", { code: "not_found", }), }); } } /** * Bulk people enrichment via FullEnrich `/v1/enrichment/person/contact`. * * Submits a single request per chunk of up to 100 contacts and returns a map * keyed by the caller's input index. Inputs that fail validation are recorded * as per-row errors instead of throwing — callers can still emit results for * the rows that did go through. */ export async function enrichPersonBulk( inputs: Array, options: EnrichmentGatewayOptions = {}, ): Promise> { const results = new Map(); const validContacts: Array<{ inputIndex: number; payload: Record; }> = []; inputs.forEach((body, index) => { if (!body) { results.set(index, { ok: false, error: new EnrichmentGatewayError("People enrichment input is empty.", { code: "empty_identifier", }), }); return; } try { validContacts.push({ inputIndex: index, payload: buildContactPayload(body), }); } catch (err) { results.set(index, { ok: false, error: err instanceof EnrichmentGatewayError ? err : new EnrichmentGatewayError( err instanceof Error ? err.message : "Invalid input", { code: "invalid_input" }, ), }); } }); if (validContacts.length === 0) return results; // Chunk and submit in parallel. const chunks: Array = []; for (let i = 0; i < validContacts.length; i += FULLENRICH_BULK_CHUNK_SIZE) { chunks.push(validContacts.slice(i, i + FULLENRICH_BULK_CHUNK_SIZE)); } await Promise.all( chunks.map((chunk) => submitContactBulkChunk(chunk, options, results)), ); return results; } async function submitReverseEmailBulkChunk( emails: Array<{ inputIndex: number; email: string }>, options: EnrichmentGatewayOptions, results: Map, ): Promise { if (emails.length === 0) return; // The gateway's reverse-email endpoint dedupes emails before calling // FullEnrich, so multiple inputs with the same address end up sharing the // same FullEnrich row. We still need to map each input index back to a // result, so we keep an email→indexes map for fan-out. const indexesByEmail = new Map(); for (const { inputIndex, email } of emails) { const arr = indexesByEmail.get(email) ?? []; arr.push(inputIndex); indexesByEmail.set(email, arr); } const raw = await callGatewayJson("/v1/enrichment/reverse/email", { method: "POST", body: { emails: emails.map((e) => e.email), preferCache: true, }, options, expectedStatus: [200, 202], }); const resp = raw as { enrichmentId?: string | null; status?: string; cachedResults?: Array; queuedCount?: number; }; const cached = resp.cachedResults ?? []; for (const hit of cached) { const idx = typeof hit.inputIndex === "number" ? hit.inputIndex : undefined; if (idx === undefined) continue; // The gateway dedupes emails, so a cache hit at index i corresponds to // the deduped email at that position in the request — we then fan out to // every original input index that requested that email. const requestEmail = emails[idx]?.email; if (!requestEmail) continue; const targets = indexesByEmail.get(requestEmail) ?? [idx]; for (const target of targets) { results.set(target, { ok: true, data: { _meta: { cacheHit: true }, ...shapePersonToApolloEnvelope(hit), }, }); } } const enrichmentId = resp.enrichmentId; if (!enrichmentId) { for (const { inputIndex } of emails) { if (results.has(inputIndex)) continue; results.set(inputIndex, { ok: false, error: new EnrichmentGatewayError("No data returned", { code: "not_found", }), }); } return; } // Poll job and demux by `contactIndex` (gateway position, which maps to // the deduped email list). Then fan out to every original input. await pollForReverseEmailResults( enrichmentId, emails, indexesByEmail, results, options, ); } async function pollForReverseEmailResults( enrichmentId: string, emails: Array<{ inputIndex: number; email: string }>, indexesByEmail: Map, results: Map, options: EnrichmentGatewayOptions, ): Promise { const pollIntervalMs = options.pollIntervalMs ?? 2000; const pollTimeoutMs = options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; const deadline = Date.now() + pollTimeoutMs; const ownedIndexes = emails .map(({ inputIndex }) => inputIndex) .filter((idx) => !results.has(idx)); while (Date.now() < deadline) { await sleep(pollIntervalMs, options.signal); let raw: Record; try { raw = await callGatewayJson( `/v1/enrichment/jobs/${encodeURIComponent(enrichmentId)}`, { method: "GET", options }, ); } catch (err) { if ( err instanceof EnrichmentGatewayError && (err.status === 404 || err.status === 503) ) { continue; } throw err; } const jobResp = raw as { status?: string; people?: Array; }; if (jobResp.status === "succeeded") { for (const person of jobResp.people ?? []) { const idx = typeof person.contactIndex === "number" ? person.contactIndex : undefined; if (idx === undefined) continue; const requestEmail = emails[idx]?.email; if (!requestEmail) continue; const targets = indexesByEmail.get(requestEmail) ?? []; for (const target of targets) { if (results.has(target)) continue; results.set(target, { ok: true, data: { _meta: { cacheHit: false, enrichmentId }, ...shapePersonToApolloEnvelope(person), }, }); } } for (const idx of ownedIndexes) { if (results.has(idx)) continue; results.set(idx, { ok: false, error: new EnrichmentGatewayError("No data returned", { code: "not_found", }), }); } return; } if (jobResp.status === "failed") { for (const idx of ownedIndexes) { if (results.has(idx)) continue; results.set(idx, { ok: false, error: new EnrichmentGatewayError("No data returned", { code: "not_found", }), }); } return; } } for (const idx of ownedIndexes) { if (results.has(idx)) continue; results.set(idx, { ok: false, error: new EnrichmentGatewayError("No data returned", { code: "not_found", }), }); } } /** * Bulk reverse-email lookup via FullEnrich `/v1/enrichment/reverse/email`. * * Submits a single request per chunk of up to 100 emails and returns a map * keyed by the caller's input index. */ export async function reverseEmailLookupBulk( inputs: Array, options: EnrichmentGatewayOptions = {}, ): Promise> { const results = new Map(); const validEmails: Array<{ inputIndex: number; email: string }> = []; inputs.forEach((rawEmail, index) => { const email = rawEmail?.trim().toLowerCase(); if (!email || !email.includes("@")) { results.set(index, { ok: false, error: new EnrichmentGatewayError("Reverse email input is empty.", { code: "empty_email", }), }); return; } validEmails.push({ inputIndex: index, email }); }); if (validEmails.length === 0) return results; const chunks: Array = []; for (let i = 0; i < validEmails.length; i += FULLENRICH_BULK_CHUNK_SIZE) { chunks.push(validEmails.slice(i, i + FULLENRICH_BULK_CHUNK_SIZE)); } await Promise.all( chunks.map((chunk) => submitReverseEmailBulkChunk(chunk, options, results)), ); return results; } // --------------------------------------------------------------------------- // reverseEmailLookup — calls POST /v1/enrichment/reverse/email with poll // --------------------------------------------------------------------------- export async function reverseEmailLookup( body: ReverseEmailBody, options: EnrichmentGatewayOptions = {}, ): Promise> { const email = body.email.trim().toLowerCase(); if (!email || !email.includes("@")) { throw new EnrichmentGatewayError("Reverse email input is empty.", { code: "empty_email", }); } const raw = await callGatewayJson("/v1/enrichment/reverse/email", { method: "POST", body: { emails: [email], preferCache: true }, options, expectedStatus: [200, 202], }); const resp = raw as { enrichmentId?: string | null; status?: string; cachedResults?: unknown[]; queuedCount?: number; }; const cachedResults = resp.cachedResults ?? []; if (cachedResults.length > 0) { const hit = cachedResults[0] as EnrichedPersonUpsertLike; return { _meta: { cacheHit: true }, ...shapePersonToApolloEnvelope(hit), } as Record; } const enrichmentId = resp.enrichmentId; if (!enrichmentId) { throw new EnrichmentGatewayError("No data returned", { code: "not_found" }); } return pollForPersonResult(enrichmentId, options); } // --------------------------------------------------------------------------- // enrichCompany — calls POST /v1/enrichment/company/search (synchronous) // --------------------------------------------------------------------------- export async function enrichCompany( body: EnrichCompanyBody, options: EnrichmentGatewayOptions = {}, ): Promise> { const domain = body.domain ? (extractDomain(body.domain) ?? body.domain) : undefined; const companyName = body.companyName?.trim(); const linkedinUrl = body.linkedinUrl?.trim(); if (!domain && !companyName && !linkedinUrl) { throw new EnrichmentGatewayError("Company enrichment input is empty.", { code: "empty_domain", }); } const requestBody: Record = { limit: 1, preferCache: true }; if (domain) requestBody.domain = domain; if (companyName) requestBody.name = companyName; if (linkedinUrl) requestBody.linkedinUrl = linkedinUrl; const raw = await callGatewayJson("/v1/enrichment/company/search", { method: "POST", body: requestBody, options, }); const resp = raw as { companies?: EnrichedCompanyUpsertLike[] }; const companies = resp.companies ?? []; if (companies.length === 0) { throw new EnrichmentGatewayError("No data returned", { code: "not_found" }); } return shapeCompanyToApolloEnvelope( companies[0] as EnrichedCompanyUpsertLike, ) as Record; } // --------------------------------------------------------------------------- export async function enrichProfiles( body: { kind: "people" | "companies"; lookups: Record[] }, options: EnrichmentGatewayOptions = {}, ): Promise> { if (body.lookups.length < 1 || body.lookups.length > 100) throw new EnrichmentGatewayError("Provide 1–100 profile lookups.", { code: "invalid_request", }); return callGatewayJson("/v1/enrichment/profiles/bulk", { method: "POST", body, options, }) as Promise>; } export type CompanyRelatedKind = | "employees" | "founders" | "funding" | "investors" | "acquisitions" | "investments"; export async function companyRelatedRecords( body: { domain?: string; linkedinUrl?: string; kind: CompanyRelatedKind; limit?: number; pageToken?: string; }, options: EnrichmentGatewayOptions = {}, ): Promise> { return callGatewayJson("/v1/enrichment/company/related", { method: "POST", body, options, }) as Promise>; } // Person profile — POST /v1/enrichment/person (synchronous waterfall) // --------------------------------------------------------------------------- export type AviatoEnrichPersonBody = { linkedinUrl?: string; linkedinID?: string; linkedinNumID?: string; linkedinEntityId?: string; email?: string; preview?: boolean; }; export type AviatoEnrichCompanyBody = { website?: string; linkedinUrl?: string; linkedinID?: string; linkedinNumID?: string; preview?: boolean; }; export async function aviatoEnrichPerson( body: AviatoEnrichPersonBody, options: EnrichmentGatewayOptions = {}, ): Promise> { const linkedinUrl = body.linkedinUrl?.trim(); const linkedinID = body.linkedinID?.trim(); const linkedinNumID = body.linkedinNumID?.trim(); const linkedinEntityId = body.linkedinEntityId?.trim(); const email = body.email?.trim(); const hasIdentifier = linkedinUrl || linkedinID || linkedinNumID || linkedinEntityId || email; if (!hasIdentifier) { throw new EnrichmentGatewayError( "Person enrichment requires at least one identifier: linkedinUrl, linkedinID, linkedinNumID, linkedinEntityId, or email.", { code: "empty_identifier" }, ); } // Aviato rejects email-only lookups in preview mode ("Email lookups are // not allowed in preview mode"), so when email is the sole identifier we // upgrade to a full (charged) lookup rather than surfacing a hard 400. const emailOnly = Boolean(email) && !linkedinUrl && !linkedinID && !linkedinNumID && !linkedinEntityId; const requestBody: Record = {}; if (linkedinUrl) requestBody.linkedinURL = linkedinUrl; if (linkedinID) requestBody.linkedinID = linkedinID; if (linkedinNumID) requestBody.linkedinNumID = linkedinNumID; if (linkedinEntityId) requestBody.linkedinEntityId = linkedinEntityId; if (email) requestBody.email = email; if (body.preview !== undefined && !(body.preview && emailOnly)) requestBody.preview = body.preview; const raw = await callGatewayJson("/v1/enrichment/person", { method: "POST", body: requestBody, options, }); const resp = raw as { data?: EnrichedPersonUpsertLike | null; billing?: { charged_cents?: number; cache_hit?: boolean; preview?: boolean; }; provider?: { name?: string; aviato_id?: string }; }; if (!resp.data) { throw new EnrichmentGatewayError("No data returned", { code: "not_found" }); } return { _meta: { cacheHit: resp.billing?.cache_hit ?? false, chargedCents: resp.billing?.charged_cents ?? 0, }, ...shapePersonToApolloEnvelope(resp.data), } as Record; } // --------------------------------------------------------------------------- // Company profile — POST /v1/enrichment/company (synchronous waterfall) // --------------------------------------------------------------------------- export async function aviatoEnrichCompany( body: AviatoEnrichCompanyBody, options: EnrichmentGatewayOptions = {}, ): Promise> { const hasIdentifier = body.website || body.linkedinUrl || body.linkedinID || body.linkedinNumID; if (!hasIdentifier) { throw new EnrichmentGatewayError( "Company enrichment requires at least one identifier: website, linkedinUrl, linkedinID, or linkedinNumID.", { code: "empty_identifier" }, ); } const requestBody: Record = {}; if (body.website) requestBody.website = body.website.trim(); if (body.linkedinUrl) requestBody.linkedinURL = body.linkedinUrl.trim(); if (body.linkedinID) requestBody.linkedinID = body.linkedinID.trim(); if (body.linkedinNumID) requestBody.linkedinNumID = body.linkedinNumID.trim(); if (body.preview !== undefined) requestBody.preview = body.preview; const raw = await callGatewayJson("/v1/enrichment/company", { method: "POST", body: requestBody, options, }); const resp = raw as { data?: EnrichedCompanyUpsertLike | null; billing?: { charged_cents?: number; cache_hit?: boolean; preview?: boolean; }; provider?: { name?: string; aviato_id?: string }; }; if (!resp.data) { throw new EnrichmentGatewayError("No data returned", { code: "not_found" }); } return { _meta: { cacheHit: resp.billing?.cache_hit ?? false, chargedCents: resp.billing?.charged_cents ?? 0, }, ...shapeCompanyToApolloEnvelope(resp.data), } as Record; } // --------------------------------------------------------------------------- // searchPeople — calls POST /v1/enrichment/people/search (synchronous) // --------------------------------------------------------------------------- export function buildPeopleSearchRequest( body: SearchPeopleBody, ): Record { // Request body matches gateway `peopleSearchRequestSchema`: // `locations`, `headcountRanges`, `titles`, `companyDomain`, etc. const payload: Record = { preferCache: body.preferCache ?? true, }; for (const key of [ "linkedinUrl", "fullName", "firstName", "lastName", "companyDomain", "companyName", "exactMatch", ] as const) { if (body[key] !== undefined) payload[key] = body[key]; } const mergeItems = ( legacy: string[] | undefined, rich: SearchFilterItem[] | undefined, ): SearchFilterItem[] => { const legacyItems = (legacy ?? []) .map((s) => s.trim()) .filter(Boolean) as SearchFilterItem[]; return [...legacyItems, ...(rich ?? [])]; }; const titles = mergeItems(body.personTitles, body.titles); if (titles.length > 0) payload.titles = titles; const locations = mergeItems(body.personLocations, body.locations); if (locations.length > 0) payload.locations = locations; const headquarters = mergeItems( body.organizationLocations, body.companyHeadquarters, ); if (headquarters.length) payload.companyHeadquarters = headquarters; const domains = mergeItems(body.organizationDomains, body.companyDomains); if (domains.length) { payload.companyDomains = domains; } const headcountRanges = mergeItems( body.organizationNumEmployeesRanges, body.headcountRanges, ); if (headcountRanges.length > 0) payload.headcountRanges = headcountRanges; const seniorities = mergeItems(body.personSeniorities, body.seniorities); if (seniorities.length > 0) payload.seniorities = seniorities; const jobFunctions = mergeItems(body.personFunctions, body.jobFunctions); if (jobFunctions.length > 0) payload.jobFunctions = jobFunctions; const industries = mergeItems(body.organizationIndustries, body.industries); if (industries.length > 0) payload.industries = industries; // Rich list filters with no legacy alias — pass straight through. const listPassthrough = [ "pastSeniorities", "profileKeywords", "companyKeywords", "pastTitles", "personNames", "subFunctions", "companyNames", "pastCompanyNames", "companyTypes", "universities", "skills", "languages", ] as const; for (const key of listPassthrough) { const items = body[key]; if (items?.length) payload[key] = items; } const rangePassthrough = [ "yearsInRole", "yearsAtCompany", "daysSinceJobChange", "foundedYears", ] as const; for (const key of rangePassthrough) { const range = body[key]; if (range && (range.min !== undefined || range.max !== undefined)) { payload[key] = range; } } const keywords = body.keywords ?? body.qKeywords; if (body.companyScope) payload.companyScope = body.companyScope; if (keywords) payload.keywords = keywords; if (body.limit !== undefined) payload.limit = body.limit; else if (body.perPage !== undefined) payload.limit = body.perPage; if (body.offset !== undefined) payload.offset = body.offset; else if (body.page !== undefined && body.page > 1) { payload.offset = (body.page - 1) * (body.limit ?? body.perPage ?? 10); } if (body.searchAfter !== undefined) payload.searchAfter = body.searchAfter; if (body.pageToken) payload.pageToken = body.pageToken; return payload; } export async function searchPeople( body: SearchPeopleBody, options: EnrichmentGatewayOptions = {}, ): Promise { return callGatewayJson("/v1/enrichment/people/search", { method: "POST", body: buildPeopleSearchRequest(body), options, }); } /** * Roughly how many people a people search matches (free; an estimate for * "N+ more", never the search's total). `scope: "roles"` counts only the * person side (roles, locations) when company filters can't be counted. * `null` when the source can't count. */ export async function countPeople( body: SearchPeopleBody, options: EnrichmentGatewayOptions = {}, ): Promise<{ estimatedTotal: number | null; scope: "profile" | "roles" | null; }> { const response = await callGatewayJson("/v1/enrichment/people/count", { method: "POST", body: buildPeopleSearchRequest(body), options, }); return { estimatedTotal: typeof response.estimatedTotal === "number" ? response.estimatedTotal : null, scope: response.scope === "profile" || response.scope === "roles" ? response.scope : null, }; } export async function searchCompanies( body: SearchCompaniesBody, options: EnrichmentGatewayOptions = {}, ): Promise { const payload: Record = { preferCache: body.preferCache ?? true, }; if (body.exactMatch !== undefined) payload.exactMatch = body.exactMatch; if (body.domain) payload.domain = body.domain; if (body.name) payload.name = body.name; if (body.linkedinUrl) payload.linkedinUrl = body.linkedinUrl; if (body.industries?.length) payload.industries = body.industries; if (body.locations?.length) payload.locations = body.locations; if (body.headcountRanges?.length) payload.headcountRanges = body.headcountRanges; if (body.keywords) payload.keywords = body.keywords; if (body.limit !== undefined) payload.limit = body.limit; if (body.offset !== undefined) payload.offset = body.offset; if (body.pageToken) payload.pageToken = body.pageToken; return callGatewayJson("/v1/enrichment/company/search", { method: "POST", body: payload, options, }); } export async function searchJobs( body: SearchJobsBody, options: EnrichmentGatewayOptions = {}, ): Promise> { const payload: Record = {}; if (body.titles?.length) payload.titles = body.titles; if (body.locations?.length) payload.locations = body.locations; if (body.companyDomain) payload.companyDomain = body.companyDomain; if (body.companyDomains?.length) payload.companyDomains = body.companyDomains; if (body.companyName) payload.companyName = body.companyName; if (body.companyNames?.length) payload.companyNames = body.companyNames; if (body.companyLinkedinUrl) payload.companyLinkedinUrl = body.companyLinkedinUrl; if (body.keywords) payload.keywords = body.keywords; if (body.postedAfter) payload.postedAfter = body.postedAfter; if (body.postedBefore) payload.postedBefore = body.postedBefore; if (body.limit !== undefined) payload.limit = body.limit; if (body.pageToken) payload.pageToken = body.pageToken; return callGatewayJson("/v1/enrichment/jobs/search", { method: "POST", body: payload, options, }); } export async function searchSignals( body: SearchSignalsBody, options: EnrichmentGatewayOptions = {}, ): Promise { const payload: Record = { signal: body.signal, postedAfter: body.postedAfter, }; if (body.titles?.length) payload.titles = body.titles; if (body.locations?.length) payload.locations = body.locations; if (body.market) payload.market = body.market; if (body.headcountRanges?.length) payload.headcountRanges = body.headcountRanges; if (body.excludeDomains?.length) payload.excludeDomains = body.excludeDomains; if (body.excludeCompanies?.length) payload.excludeCompanies = body.excludeCompanies; if (body.includeCompanies?.length) payload.includeCompanies = body.includeCompanies; if (body.industries?.length) payload.industries = body.industries; if (body.limit !== undefined) payload.limit = body.limit; if (body.terms?.length) payload.terms = body.terms; if (body.recency) payload.recency = body.recency; if (body.mode) payload.mode = body.mode; return (await callGatewayJson("/v1/enrichment/signals/search", { method: "POST", body: payload, options: { ...options, signal: withTimeout( options.signal, body.mode === "volume" ? SIGNAL_GATEWAY_TIMEOUTS_MS.volume : SIGNAL_GATEWAY_TIMEOUTS_MS.search, ), }, })) as SearchSignalsResponse; } export const SIGNAL_GATEWAY_TIMEOUTS_MS = { search: 140_000, volume: 75_000, buyer: 75_000, reveal: 75_000, } as const; function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal { const timeout = AbortSignal.timeout(ms); return signal && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : (signal ?? timeout); } export type SignalBuyerBody = { companyDomain: string; titles?: string[]; /** Where the person is; the gateway drops people placed elsewhere. */ locations?: string[]; /** The company's industries, checked against the person's job. */ industries?: string[]; /** Kinds of company (Public, Nonprofit, …), checked on the company. */ companyTypes?: string[]; withEmail?: boolean; limit?: number; }; export type SignalBuyer = { name: string; firstName?: string; title?: string; linkedinUrl?: string; avatarUrl?: string; email?: string; }; export type SignalBuyerResponse = { buyer: SignalBuyer | null; buyers?: SignalBuyer[]; reveal?: "found" | "not_found" | "failed"; }; export type SignalPersonRevealBody = { companyDomain: string; person: { name: string; linkedinUrl: string; title?: string; firstName?: string; }; }; export async function findSignalBuyer( body: SignalBuyerBody, options: EnrichmentGatewayOptions = {}, ): Promise { const payload: Record = { companyDomain: body.companyDomain, }; if (body.titles?.length) payload.titles = body.titles; if (body.locations?.length) payload.locations = body.locations; if (body.industries?.length) payload.industries = body.industries; if (body.companyTypes?.length) payload.companyTypes = body.companyTypes; if (body.withEmail !== undefined) payload.withEmail = body.withEmail; if (body.limit !== undefined) payload.limit = body.limit; return (await callGatewayJson("/v1/enrichment/signals/buyer", { method: "POST", body: payload, options: { ...options, signal: withTimeout(options.signal, SIGNAL_GATEWAY_TIMEOUTS_MS.buyer), }, })) as SignalBuyerResponse; } export async function revealSignalPersonEmail( body: SignalPersonRevealBody, options: EnrichmentGatewayOptions = {}, ): Promise { return (await callGatewayJson("/v1/enrichment/signals/buyer", { method: "POST", body: { companyDomain: body.companyDomain, person: { name: body.person.name, linkedinUrl: body.person.linkedinUrl, ...(body.person.title ? { title: body.person.title } : {}), ...(body.person.firstName ? { firstName: body.person.firstName } : {}), }, }, options: { ...options, signal: withTimeout(options.signal, SIGNAL_GATEWAY_TIMEOUTS_MS.reveal), }, })) as SignalBuyerResponse; } /** * Number of Exa candidates to pull when resolving ONE person for enrichment. * * Kept small on purpose. Exa's per-request price includes the first 10 * results and charges per result beyond that, and column enrichment issues * one search PER ROW — so a bulk fill over 500 rows is 500 searches. Five is * enough for the match gate to find the right person when they are in the * index, and stays inside the included tier. */ export const EXA_ENRICHMENT_NUM_RESULTS = 5; /** * Exa People Search through the gateway's `/v1/search` route. * * Unlike the other helpers in this file this is a SEARCH, not an identifier * lookup — it returns relevance-ranked strangers, so callers must run the * result through `matchExaPerson` before writing anything. See * cli/lib/exa-people.ts. * * `type` is pinned to "auto": "deep"/"deep-reasoning" cost roughly 2x and buy * nothing when we are resolving a person we can already name. */ export async function searchExaPeople( body: { query: string; numResults?: number }, options: EnrichmentGatewayOptions = {}, ): Promise> { return callGatewayJson("/v1/search", { method: "POST", body: { query: body.query, category: "people", type: "auto", numResults: body.numResults ?? EXA_ENRICHMENT_NUM_RESULTS, contents: { highlights: { maxCharacters: 200 } }, }, options, }); } /** * Exa Company Search through the gateway's `/v1/search` route. Same * search-not-lookup caveat as `searchExaPeople`: run the result through * `matchExaCompany` before writing anything. See cli/lib/exa-companies.ts. */ export async function searchExaCompanies( body: { query: string; numResults?: number }, options: EnrichmentGatewayOptions = {}, ): Promise> { return callGatewayJson("/v1/search", { method: "POST", body: { query: body.query, category: "company", type: "auto", numResults: body.numResults ?? EXA_ENRICHMENT_NUM_RESULTS, contents: { highlights: { maxCharacters: 200 } }, }, options, }); } // --------------------------------------------------------------------------- // Shared HTTP helpers // --------------------------------------------------------------------------- async function callGatewayJson( pathOrUrl: string | URL, args: { method: "GET" | "POST"; body?: Record; options: EnrichmentGatewayOptions; /** HTTP statuses considered "ok" beyond the default 2xx check. */ expectedStatus?: number[]; }, ): Promise> { const fetchImpl = args.options.fetchImpl ?? fetch; const apiKey = args.options.apiKey ?? requireEnrichmentApiKey(args.options.env); const url = typeof pathOrUrl === "string" ? `${resolveBaseUrl(args.options)}${pathOrUrl}` : pathOrUrl; const response = await fetchImpl(url, { method: args.method, headers: { ...(args.method === "POST" ? { "content-type": "application/json" } : {}), Authorization: `Bearer ${apiKey}`, }, ...(args.body ? { body: JSON.stringify(args.body) } : {}), ...(args.options.signal ? { signal: args.options.signal } : {}), }); const isOk = response.ok || (args.expectedStatus?.includes(response.status) ?? false); if (!isOk) { throw await buildGatewayError(response, pathOrUrl.toString()); } return (await response.json()) as Record; } function resolveBaseUrl(options: EnrichmentGatewayOptions): string { return options.baseUrl != null ? normalizeGatewayBaseUrl(options.baseUrl.trim()) : resolveEnrichmentGatewayBaseUrl(options.env); } async function buildGatewayError( response: Response, context: string, ): Promise { let body: unknown = null; let rawText = ""; try { rawText = await response.text(); body = rawText ? JSON.parse(rawText) : null; } catch { // Non-JSON bodies are surfaced below. } const error = body && typeof body === "object" ? (body as { error?: { code?: unknown; message?: unknown } }).error : undefined; const code = typeof error?.code === "string" ? error.code : undefined; const upstreamMessage = typeof error?.message === "string" ? error.message : undefined; if (response.status === 404 || code === "not_found") { return new EnrichmentGatewayError("No data returned", { status: response.status, code: code ?? "not_found", }); } if ( response.status === 401 || code === "invalid_api_key" || code === "unauthorized" || code === "aviato_unauthorized" ) { return new EnrichmentGatewayError( upstreamMessage ?? "Unauthorized gateway request", { status: response.status, code: "unauthorized" }, ); } if (response.status === 403 || code === "forbidden") { return new EnrichmentGatewayError( upstreamMessage ?? "Forbidden gateway request", { status: response.status, code: "forbidden" }, ); } const billingPreflightMessage = enrichmentBillingPreflightMessage({ status: response.status, code, }); if (billingPreflightMessage) { return new EnrichmentGatewayError(billingPreflightMessage, { status: response.status, code, }); } if (response.status === 503 || code === "provider_unavailable") { return new EnrichmentGatewayError("Gateway providers unavailable", { status: response.status, code: code ?? "provider_unavailable", }); } if (code === "invalid_required_field") { return new EnrichmentGatewayError( upstreamMessage ?? "Invalid required field", { status: response.status, code }, ); } if ( response.status === 402 || code === "on_demand_disabled" || code === "insufficient_credits" || code === "spend_limit_reached" ) { return new EnrichmentGatewayError( upstreamMessage ?? "AI credits exhausted.", { status: response.status, code }, ); } const detail = upstreamMessage || rawText || response.statusText; const truncated = detail.length > 500 ? `${detail.slice(0, 500)}...` : detail; return new EnrichmentGatewayError( `Gateway ${context} returned ${response.status}: ${truncated}`, { status: response.status, code }, ); } function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new EnrichmentGatewayError("aborted", { code: "aborted" })); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(timer); reject(new EnrichmentGatewayError("aborted", { code: "aborted" })); }; signal?.addEventListener("abort", onAbort, { once: true }); }); }