chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { companyDomainEnrichment } from '@/enrichments/company-domain/company-domain'
|
||||
import type { EnrichmentProvider } from '@/enrichments/types'
|
||||
|
||||
function provider(id: string): EnrichmentProvider {
|
||||
const p = companyDomainEnrichment.providers.find((x) => x.id === id)
|
||||
if (!p) throw new Error(`Provider ${id} not found in company-domain cascade`)
|
||||
return p
|
||||
}
|
||||
|
||||
const nameInput = { companyName: 'Acme Inc' }
|
||||
|
||||
describe('company-domain enrichment cascade', () => {
|
||||
it('chains PDL then Datagma', () => {
|
||||
expect(companyDomainEnrichment.providers.map((p) => p.id)).toEqual(['pdl', 'datagma'])
|
||||
})
|
||||
|
||||
describe('pdl', () => {
|
||||
const p = provider('pdl')
|
||||
it('matches by name and normalizes the returned website', () => {
|
||||
expect(p.toolId).toBe('pdl_company_enrich')
|
||||
expect(p.buildParams(nameInput)).toEqual({ name: 'Acme Inc', required: 'website' })
|
||||
expect(p.buildParams({ companyName: '' })).toBeNull()
|
||||
expect(p.mapOutput({ company: { website: 'https://www.acme.com' } })).toEqual({
|
||||
domain: 'acme.com',
|
||||
})
|
||||
expect(p.mapOutput({ company: {} })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('datagma', () => {
|
||||
const p = provider('datagma')
|
||||
it('enriches by company name and normalizes the returned website', () => {
|
||||
expect(p.toolId).toBe('datagma_enrich_company')
|
||||
expect(p.buildParams(nameInput)).toEqual({ data: 'Acme Inc' })
|
||||
expect(p.buildParams({ companyName: '' })).toBeNull()
|
||||
expect(p.mapOutput({ website: 'https://www.acme.com/' })).toEqual({ domain: 'acme.com' })
|
||||
expect(p.mapOutput({})).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Globe } from 'lucide-react'
|
||||
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
|
||||
import type { EnrichmentConfig } from '@/enrichments/types'
|
||||
|
||||
/**
|
||||
* Company Domain enrichment. Resolves a company's website domain from its name
|
||||
* via a People Data Labs company match, falling back to Datagma's company enrich.
|
||||
*/
|
||||
export const companyDomainEnrichment: EnrichmentConfig = {
|
||||
id: 'company-domain',
|
||||
name: 'Company Domain',
|
||||
description: "Find a company's website domain from its name.",
|
||||
icon: Globe,
|
||||
inputs: [{ id: 'companyName', name: 'Company name', type: 'string', required: true }],
|
||||
outputs: [{ id: 'domain', name: 'domain', type: 'string' }],
|
||||
providers: [
|
||||
toolProvider({
|
||||
id: 'pdl',
|
||||
label: 'People Data Labs',
|
||||
toolId: 'pdl_company_enrich',
|
||||
buildParams: (inputs) => {
|
||||
const name = str(inputs.companyName)
|
||||
if (!name) return null
|
||||
// `required` makes PDL 404 (free) when the match has no website,
|
||||
// instead of charging a credit for a match we'd discard as a no-match.
|
||||
return { name, required: 'website' }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const company = output.company as Record<string, unknown> | undefined
|
||||
const domain = normalizeDomain(company?.website)
|
||||
return domain ? { domain } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'datagma',
|
||||
label: 'Datagma',
|
||||
toolId: 'datagma_enrich_company',
|
||||
buildParams: (inputs) => {
|
||||
// Datagma's `data` accepts a company name and returns its website.
|
||||
const data = str(inputs.companyName)
|
||||
if (!data) return null
|
||||
return { data }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const domain = normalizeDomain(output.website)
|
||||
return domain ? { domain } : null
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { companyDomainEnrichment } from './company-domain'
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { companyInfoEnrichment } from '@/enrichments/company-info/company-info'
|
||||
import type { EnrichmentProvider } from '@/enrichments/types'
|
||||
|
||||
function provider(id: string): EnrichmentProvider {
|
||||
const p = companyInfoEnrichment.providers.find((x) => x.id === id)
|
||||
if (!p) throw new Error(`Provider ${id} not found in company-info cascade`)
|
||||
return p
|
||||
}
|
||||
|
||||
const domainInput = { domain: 'https://www.acme.com/about' }
|
||||
|
||||
describe('company-info enrichment cascade', () => {
|
||||
it('chains the company-info providers in waterfall order', () => {
|
||||
expect(companyInfoEnrichment.providers.map((p) => p.id)).toEqual([
|
||||
'hunter',
|
||||
'pdl',
|
||||
'datagma',
|
||||
'leadmagic',
|
||||
])
|
||||
})
|
||||
|
||||
describe('hunter', () => {
|
||||
const p = provider('hunter')
|
||||
it('normalizes the domain and maps size/description', () => {
|
||||
expect(p.toolId).toBe('hunter_companies_find')
|
||||
expect(p.buildParams(domainInput)).toEqual({ domain: 'acme.com' })
|
||||
expect(p.buildParams({ domain: '' })).toBeNull()
|
||||
expect(p.mapOutput({ size: '11-50', description: 'Payments' })).toEqual({
|
||||
employeeCount: '11-50',
|
||||
description: 'Payments',
|
||||
})
|
||||
expect(p.mapOutput({})).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('datagma', () => {
|
||||
const p = provider('datagma')
|
||||
it('passes the normalized domain as data and maps companySize/shortDescription', () => {
|
||||
expect(p.toolId).toBe('datagma_enrich_company')
|
||||
expect(p.buildParams(domainInput)).toEqual({ data: 'acme.com' })
|
||||
expect(p.buildParams({ domain: '' })).toBeNull()
|
||||
expect(p.mapOutput({ companySize: '11-50', shortDescription: 'Payments' })).toEqual({
|
||||
employeeCount: '11-50',
|
||||
description: 'Payments',
|
||||
})
|
||||
expect(p.mapOutput({})).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('leadmagic', () => {
|
||||
const p = provider('leadmagic')
|
||||
it('searches by domain and prefers the headcount range', () => {
|
||||
expect(p.toolId).toBe('leadmagic_company_search')
|
||||
expect(p.buildParams(domainInput)).toEqual({ company_domain: 'acme.com' })
|
||||
expect(p.buildParams({ domain: '' })).toBeNull()
|
||||
expect(
|
||||
p.mapOutput({ employeeRange: '11-50', employeeCount: 42, description: 'Pay' })
|
||||
).toEqual({ employeeCount: '11-50', description: 'Pay' })
|
||||
expect(p.mapOutput({ employeeCount: 42 })).toEqual({ employeeCount: '42' })
|
||||
expect(p.mapOutput({})).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
|
||||
import type { EnrichmentConfig } from '@/enrichments/types'
|
||||
|
||||
/**
|
||||
* Company Info enrichment. Looks up a company by domain, trying Hunter first
|
||||
* (free) then People Data Labs, then Datagma and LeadMagic as fallbacks. Outputs
|
||||
* are limited to the fields the providers reliably return — employee count and
|
||||
* description — so the result stays consistent regardless of which provider fills
|
||||
* the cell. `employeeCount` is a string so Hunter's range bucket (e.g. `"11-50"`),
|
||||
* PDL's exact count, and LeadMagic's range all map onto the same column.
|
||||
*/
|
||||
export const companyInfoEnrichment: EnrichmentConfig = {
|
||||
id: 'company-info',
|
||||
name: 'Company Info',
|
||||
description: "Look up a company's size and description from its domain.",
|
||||
icon: Building2,
|
||||
inputs: [{ id: 'domain', name: 'Company domain', type: 'string', required: true }],
|
||||
outputs: [
|
||||
{ id: 'employeeCount', name: 'employee count', type: 'string' },
|
||||
{ id: 'description', name: 'description', type: 'string' },
|
||||
],
|
||||
providers: [
|
||||
toolProvider({
|
||||
id: 'hunter',
|
||||
label: 'Hunter',
|
||||
toolId: 'hunter_companies_find',
|
||||
buildParams: (inputs) => {
|
||||
const domain = normalizeDomain(inputs.domain)
|
||||
if (!domain) return null
|
||||
return { domain }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
return filterUndefined({
|
||||
employeeCount: str(output.size) || undefined,
|
||||
description: str(output.description) || undefined,
|
||||
})
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'pdl',
|
||||
label: 'People Data Labs',
|
||||
toolId: 'pdl_company_enrich',
|
||||
buildParams: (inputs) => {
|
||||
const website = normalizeDomain(inputs.domain)
|
||||
if (!website) return null
|
||||
// `required` makes PDL 404 (free) when neither field we extract is
|
||||
// present, instead of charging a credit for a match we'd discard.
|
||||
return { website, required: 'employee_count OR summary' }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const company = output.company as Record<string, unknown> | undefined
|
||||
return filterUndefined({
|
||||
employeeCount: str(company?.employee_count) || undefined,
|
||||
description: str(company?.summary) || undefined,
|
||||
})
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'datagma',
|
||||
label: 'Datagma',
|
||||
toolId: 'datagma_enrich_company',
|
||||
buildParams: (inputs) => {
|
||||
const data = normalizeDomain(inputs.domain)
|
||||
if (!data) return null
|
||||
return { data }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
return filterUndefined({
|
||||
employeeCount: str(output.companySize) || undefined,
|
||||
description: str(output.shortDescription) || undefined,
|
||||
})
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'leadmagic',
|
||||
label: 'LeadMagic',
|
||||
toolId: 'leadmagic_company_search',
|
||||
buildParams: (inputs) => {
|
||||
const companyDomain = normalizeDomain(inputs.domain)
|
||||
if (!companyDomain) return null
|
||||
return { company_domain: companyDomain }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
// Prefer the headcount range to match Hunter's bucket style; fall back to the exact count.
|
||||
return filterUndefined({
|
||||
employeeCount: str(output.employeeRange) || str(output.employeeCount) || undefined,
|
||||
description: str(output.description) || undefined,
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { companyInfoEnrichment } from './company-info'
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emailVerificationEnrichment } from '@/enrichments/email-verification/email-verification'
|
||||
import type { EnrichmentProvider } from '@/enrichments/types'
|
||||
|
||||
function provider(id: string): EnrichmentProvider {
|
||||
const p = emailVerificationEnrichment.providers.find((x) => x.id === id)
|
||||
if (!p) throw new Error(`Provider ${id} not found in email-verification cascade`)
|
||||
return p
|
||||
}
|
||||
|
||||
const emailInput = { email: ' john@acme.com ' }
|
||||
|
||||
describe('email-verification enrichment cascade', () => {
|
||||
it('chains the hosted verifiers in waterfall order', () => {
|
||||
expect(emailVerificationEnrichment.providers.map((p) => p.id)).toEqual([
|
||||
'zerobounce',
|
||||
'neverbounce',
|
||||
'millionverifier',
|
||||
'enrow',
|
||||
])
|
||||
})
|
||||
|
||||
describe('zerobounce', () => {
|
||||
const p = provider('zerobounce')
|
||||
it('trims the email and falls through on missing/unknown verdict', () => {
|
||||
expect(p.toolId).toBe('zerobounce_verify_email')
|
||||
expect(p.buildParams(emailInput)).toEqual({ email: 'john@acme.com' })
|
||||
expect(p.buildParams({ email: '' })).toBeNull()
|
||||
expect(p.mapOutput({ status: 'valid', deliverable: true })).toEqual({
|
||||
status: 'valid',
|
||||
deliverable: true,
|
||||
})
|
||||
expect(p.mapOutput({ status: 'unknown', deliverable: false })).toBeNull()
|
||||
expect(p.mapOutput({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('enrow', () => {
|
||||
const p = provider('enrow')
|
||||
it('maps the valid/invalid qualifier and falls through otherwise', () => {
|
||||
expect(p.toolId).toBe('enrow_verify_email')
|
||||
expect(p.buildParams(emailInput)).toEqual({ email: 'john@acme.com' })
|
||||
expect(p.buildParams({ email: '' })).toBeNull()
|
||||
expect(p.mapOutput({ qualification: 'valid' })).toEqual({
|
||||
status: 'valid',
|
||||
deliverable: true,
|
||||
})
|
||||
expect(p.mapOutput({ qualification: 'invalid' })).toEqual({
|
||||
status: 'invalid',
|
||||
deliverable: false,
|
||||
})
|
||||
expect(p.mapOutput({ qualification: null })).toBeNull()
|
||||
expect(p.mapOutput({})).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ShieldCheck } from '@sim/emcn/icons'
|
||||
import { str, toolProvider } from '@/enrichments/providers'
|
||||
import type { EnrichmentConfig } from '@/enrichments/types'
|
||||
|
||||
/**
|
||||
* Email Verification enrichment. Checks an email address's deliverability via a
|
||||
* verifier waterfall — ZeroBounce first (highest coverage), then NeverBounce,
|
||||
* then MillionVerifier, then Enrow. A provider that returns a
|
||||
* definitive verdict (valid / invalid / catch_all / disposable / etc.) fills the
|
||||
* cell; a provider that can only return `unknown` falls through to the next so
|
||||
* the row gets the most confident answer available. All providers support hosted
|
||||
* keys.
|
||||
*/
|
||||
export const emailVerificationEnrichment: EnrichmentConfig = {
|
||||
id: 'email-verification',
|
||||
name: 'Email Verification',
|
||||
description: "Check an email address's deliverability and risk status.",
|
||||
icon: ShieldCheck,
|
||||
inputs: [{ id: 'email', name: 'Email', type: 'string', required: true }],
|
||||
outputs: [
|
||||
{ id: 'status', name: 'status', type: 'string' },
|
||||
{ id: 'deliverable', name: 'deliverable', type: 'boolean' },
|
||||
],
|
||||
providers: [
|
||||
toolProvider({
|
||||
id: 'zerobounce',
|
||||
label: 'ZeroBounce',
|
||||
toolId: 'zerobounce_verify_email',
|
||||
buildParams: (inputs) => {
|
||||
const email = str(inputs.email)
|
||||
if (!email) return null
|
||||
return { email }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const status = str(output.status)
|
||||
// Fall through to the next verifier when the verdict is missing or inconclusive.
|
||||
if (!status || status === 'unknown') return null
|
||||
return { status, deliverable: output.deliverable === true }
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'neverbounce',
|
||||
label: 'NeverBounce',
|
||||
toolId: 'neverbounce_verify_email',
|
||||
buildParams: (inputs) => {
|
||||
const email = str(inputs.email)
|
||||
if (!email) return null
|
||||
return { email }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const status = str(output.status)
|
||||
if (!status || status === 'unknown') return null
|
||||
return { status, deliverable: output.deliverable === true }
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'millionverifier',
|
||||
label: 'MillionVerifier',
|
||||
toolId: 'millionverifier_verify_email',
|
||||
buildParams: (inputs) => {
|
||||
const email = str(inputs.email)
|
||||
if (!email) return null
|
||||
return { email }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const status = str(output.status)
|
||||
if (!status || status === 'unknown') return null
|
||||
return { status, deliverable: output.deliverable === true }
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'enrow',
|
||||
label: 'Enrow',
|
||||
toolId: 'enrow_verify_email',
|
||||
buildParams: (inputs) => {
|
||||
const email = str(inputs.email)
|
||||
if (!email) return null
|
||||
return { email }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
// Enrow returns a "valid" / "invalid" qualifier; anything else is inconclusive.
|
||||
const qualification = str(output.qualification).toLowerCase()
|
||||
if (qualification === 'valid') return { status: 'valid', deliverable: true }
|
||||
if (qualification === 'invalid') return { status: 'invalid', deliverable: false }
|
||||
return null
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { emailVerificationEnrichment } from './email-verification'
|
||||
@@ -0,0 +1,9 @@
|
||||
export { ALL_ENRICHMENTS, ENRICHMENT_REGISTRY, getEnrichment } from './registry'
|
||||
export type {
|
||||
EnrichmentConfig,
|
||||
EnrichmentInputField,
|
||||
EnrichmentOutputField,
|
||||
EnrichmentProvider,
|
||||
EnrichmentRegistry,
|
||||
EnrichmentRunContext,
|
||||
} from './types'
|
||||
@@ -0,0 +1 @@
|
||||
export { phoneNumberEnrichment } from './phone-number'
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { phoneNumberEnrichment } from '@/enrichments/phone-number/phone-number'
|
||||
import type { EnrichmentProvider } from '@/enrichments/types'
|
||||
|
||||
function provider(id: string): EnrichmentProvider {
|
||||
const p = phoneNumberEnrichment.providers.find((x) => x.id === id)
|
||||
if (!p) throw new Error(`Provider ${id} not found in phone-number cascade`)
|
||||
return p
|
||||
}
|
||||
|
||||
const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
|
||||
const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' }
|
||||
|
||||
describe('phone-number enrichment cascade', () => {
|
||||
it('chains PDL then the phone-capable hosted providers', () => {
|
||||
expect(phoneNumberEnrichment.providers.map((p) => p.id)).toEqual([
|
||||
'pdl',
|
||||
'wiza',
|
||||
'findymail',
|
||||
'prospeo',
|
||||
'leadmagic',
|
||||
'datagma',
|
||||
])
|
||||
})
|
||||
|
||||
describe('wiza (opportunistic)', () => {
|
||||
const p = provider('wiza')
|
||||
it('reveals phone, using name+domain or LinkedIn profile_url', () => {
|
||||
expect(p.toolId).toBe('wiza_individual_reveal')
|
||||
expect(p.buildParams(nameDomain)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
domain: 'acme.com',
|
||||
enrichment_level: 'phone',
|
||||
})
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
profile_url: 'https://linkedin.com/in/johndoe',
|
||||
enrichment_level: 'phone',
|
||||
})
|
||||
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
|
||||
expect(p.mapOutput({ mobile_phone: '+1555', phones: [] })).toEqual({ phone: '+1555' })
|
||||
expect(p.mapOutput({ phones: [{ number: '+1777' }] })).toEqual({ phone: '+1777' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('findymail', () => {
|
||||
const p = provider('findymail')
|
||||
it('keys off the LinkedIn URL and skips without one', () => {
|
||||
expect(p.toolId).toBe('findymail_find_phone')
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({
|
||||
linkedin_url: 'https://linkedin.com/in/johndoe',
|
||||
})
|
||||
expect(p.buildParams(nameDomain)).toBeNull()
|
||||
expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' })
|
||||
expect(p.mapOutput({ phone: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prospeo (opportunistic)', () => {
|
||||
const p = provider('prospeo')
|
||||
it('requests mobile enrichment via name+domain or LinkedIn', () => {
|
||||
expect(p.toolId).toBe('prospeo_enrich_person')
|
||||
expect(p.buildParams(nameDomain)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
company_website: 'acme.com',
|
||||
enrich_mobile: true,
|
||||
})
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
linkedin_url: 'https://linkedin.com/in/johndoe',
|
||||
enrich_mobile: true,
|
||||
})
|
||||
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
|
||||
expect(p.mapOutput({ person: { mobile: { mobile: '+1555' } } })).toEqual({ phone: '+1555' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('leadmagic', () => {
|
||||
const p = provider('leadmagic')
|
||||
it('keys off the LinkedIn URL and skips without one', () => {
|
||||
expect(p.toolId).toBe('leadmagic_find_mobile')
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({
|
||||
profile_url: 'https://linkedin.com/in/johndoe',
|
||||
})
|
||||
expect(p.buildParams(nameDomain)).toBeNull()
|
||||
expect(p.mapOutput({ mobile_number: '+1555' })).toEqual({ phone: '+1555' })
|
||||
expect(p.mapOutput({ mobile_number: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('datagma', () => {
|
||||
const p = provider('datagma')
|
||||
it('passes the LinkedIn URL as username and skips without one', () => {
|
||||
expect(p.toolId).toBe('datagma_find_phone')
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({ username: 'https://linkedin.com/in/johndoe' })
|
||||
expect(p.buildParams(nameDomain)).toBeNull()
|
||||
expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' })
|
||||
expect(p.mapOutput({ phone: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
import { Phone } from 'lucide-react'
|
||||
import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments/providers'
|
||||
import type { EnrichmentConfig } from '@/enrichments/types'
|
||||
|
||||
/**
|
||||
* Phone Number enrichment. Finds a contact's phone number from a full name plus
|
||||
* any available identifiers (company domain, LinkedIn URL) via a waterfall:
|
||||
* People Data Labs (name match) → Wiza reveal → Findymail (LinkedIn) → Prospeo
|
||||
* mobile → LeadMagic (LinkedIn) → Datagma (LinkedIn). Each provider
|
||||
* opportunistically uses whatever identifiers the row provides and self-skips
|
||||
* when it has none usable, so adding more inputs widens coverage without
|
||||
* reordering. First phone wins; all providers support hosted keys.
|
||||
*/
|
||||
export const phoneNumberEnrichment: EnrichmentConfig = {
|
||||
id: 'phone-number',
|
||||
name: 'Phone Number',
|
||||
description: "Find a contact's phone number from their name, company, or LinkedIn URL.",
|
||||
icon: Phone,
|
||||
inputs: [
|
||||
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
|
||||
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
|
||||
{ id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' },
|
||||
],
|
||||
outputs: [{ id: 'phone', name: 'phone', type: 'string' }],
|
||||
providers: [
|
||||
toolProvider({
|
||||
id: 'pdl',
|
||||
label: 'People Data Labs',
|
||||
toolId: 'pdl_person_enrich',
|
||||
buildParams: (inputs) => {
|
||||
const name = str(inputs.fullName)
|
||||
if (!name) return null
|
||||
// `required` makes PDL 404 (free) when the profile has no phone,
|
||||
// instead of charging a credit for a match we'd discard as a no-match.
|
||||
return filterUndefined({
|
||||
name,
|
||||
company: normalizeDomain(inputs.companyDomain) || undefined,
|
||||
min_likelihood: 6,
|
||||
required: 'phone_numbers OR mobile_phone',
|
||||
})
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const person = output.person as Record<string, unknown> | undefined
|
||||
const phone = firstNonEmpty(person?.phone_numbers) ?? str(person?.mobile_phone)
|
||||
return phone ? { phone } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'wiza',
|
||||
label: 'Wiza',
|
||||
toolId: 'wiza_individual_reveal',
|
||||
buildParams: (inputs) => {
|
||||
const linkedin = str(inputs.linkedinUrl)
|
||||
const fullName = str(inputs.fullName)
|
||||
const domain = normalizeDomain(inputs.companyDomain)
|
||||
// Needs a LinkedIn URL or a name+domain pair; skip otherwise.
|
||||
if (!linkedin && !(fullName && domain)) return null
|
||||
// 'phone' reveals the mobile number (5 credits). Prefer LinkedIn when present.
|
||||
return filterUndefined({
|
||||
profile_url: linkedin || undefined,
|
||||
full_name: fullName || undefined,
|
||||
domain: domain || undefined,
|
||||
enrichment_level: 'phone',
|
||||
})
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const phones = Array.isArray(output.phones)
|
||||
? (output.phones as Record<string, unknown>[])
|
||||
: []
|
||||
const phone = str(output.mobile_phone) || str(output.phone_number) || str(phones[0]?.number)
|
||||
return phone ? { phone } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'findymail',
|
||||
label: 'Findymail',
|
||||
toolId: 'findymail_find_phone',
|
||||
buildParams: (inputs) => {
|
||||
// Findymail's phone finder keys off a LinkedIn URL only.
|
||||
const linkedin = str(inputs.linkedinUrl)
|
||||
if (!linkedin) return null
|
||||
return { linkedin_url: linkedin }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const phone = str(output.phone)
|
||||
return phone ? { phone } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'prospeo',
|
||||
label: 'Prospeo',
|
||||
toolId: 'prospeo_enrich_person',
|
||||
buildParams: (inputs) => {
|
||||
const linkedin = str(inputs.linkedinUrl)
|
||||
const fullName = str(inputs.fullName)
|
||||
const companyWebsite = normalizeDomain(inputs.companyDomain)
|
||||
if (!linkedin && !(fullName && companyWebsite)) return null
|
||||
return filterUndefined({
|
||||
linkedin_url: linkedin || undefined,
|
||||
full_name: fullName || undefined,
|
||||
company_website: companyWebsite || undefined,
|
||||
enrich_mobile: true,
|
||||
})
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const person = output.person as Record<string, unknown> | undefined
|
||||
const mobile = person?.mobile as Record<string, unknown> | undefined
|
||||
const phone = str(mobile?.mobile)
|
||||
return phone ? { phone } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'leadmagic',
|
||||
label: 'LeadMagic',
|
||||
toolId: 'leadmagic_find_mobile',
|
||||
buildParams: (inputs) => {
|
||||
// LeadMagic's mobile finder keys off a LinkedIn URL.
|
||||
const profileUrl = str(inputs.linkedinUrl)
|
||||
if (!profileUrl) return null
|
||||
return { profile_url: profileUrl }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const phone = str(output.mobile_number)
|
||||
return phone ? { phone } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'datagma',
|
||||
label: 'Datagma',
|
||||
toolId: 'datagma_find_phone',
|
||||
buildParams: (inputs) => {
|
||||
// Datagma's phone finder takes the full LinkedIn URL as `username`.
|
||||
const username = str(inputs.linkedinUrl)
|
||||
if (!username) return null
|
||||
return { username }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const phone = str(output.phone)
|
||||
return phone ? { phone } : null
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
EnrichmentInputField,
|
||||
EnrichmentOutputField,
|
||||
EnrichmentProvider,
|
||||
} from '@/enrichments/types'
|
||||
|
||||
/**
|
||||
* Narrow union of the field types enrichments declare. Assignable to both the
|
||||
* tool `OutputType` and the block `ParamType` unions, so a single mapping
|
||||
* function feeds both sides without per-call casts.
|
||||
*/
|
||||
export type EnrichmentFieldType = 'string' | 'number' | 'boolean' | 'json'
|
||||
|
||||
/** Maps an enrichment input/output column type to a block/tool field type. */
|
||||
export function mapFieldType(
|
||||
type: EnrichmentInputField['type'] | EnrichmentOutputField['type']
|
||||
): EnrichmentFieldType {
|
||||
if (type === 'number') return 'number'
|
||||
if (type === 'boolean') return 'boolean'
|
||||
if (type === 'json') return 'json'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
/** Coerces an unknown input value to a trimmed string (`''` when nullish). */
|
||||
export function str(value: unknown): string {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
/** Strips protocol / path / leading `www.` from a domain-ish input. */
|
||||
export function normalizeDomain(value: unknown): string {
|
||||
return str(value)
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/.*$/, '')
|
||||
}
|
||||
|
||||
/** Returns the first non-empty string in an array (or `undefined`). */
|
||||
export function firstNonEmpty(value: unknown): string | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
for (const item of value) {
|
||||
const s = str(item)
|
||||
if (s) return s
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a full name into first / last for providers whose API requires both
|
||||
* (e.g. Hunter). Returns `null` when the name has fewer than two parts, so the
|
||||
* provider falls through to one that accepts a single name string.
|
||||
*/
|
||||
export function splitName(fullName: unknown): { firstName: string; lastName: string } | null {
|
||||
const parts = str(fullName).split(/\s+/).filter(Boolean)
|
||||
if (parts.length < 2) return null
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a tool-backed enrichment provider as plain data. Keeping this free of
|
||||
* any `@/tools` reference (the cascade runner does the `executeTool` call) means
|
||||
* the enrichment catalog stays client-safe — the table UI imports it only for
|
||||
* metadata. Workspace scope and BYOK / hosted-key injection are handled by the
|
||||
* runner when it executes `toolId`.
|
||||
*/
|
||||
export function toolProvider(provider: EnrichmentProvider): EnrichmentProvider {
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { companyDomainEnrichment } from '@/enrichments/company-domain'
|
||||
import { companyInfoEnrichment } from '@/enrichments/company-info'
|
||||
import { emailVerificationEnrichment } from '@/enrichments/email-verification'
|
||||
import { phoneNumberEnrichment } from '@/enrichments/phone-number'
|
||||
import type { EnrichmentConfig, EnrichmentRegistry } from '@/enrichments/types'
|
||||
import { workEmailEnrichment } from '@/enrichments/work-email'
|
||||
|
||||
export const ENRICHMENT_REGISTRY: EnrichmentRegistry = {
|
||||
[workEmailEnrichment.id]: workEmailEnrichment,
|
||||
[emailVerificationEnrichment.id]: emailVerificationEnrichment,
|
||||
[phoneNumberEnrichment.id]: phoneNumberEnrichment,
|
||||
[companyDomainEnrichment.id]: companyDomainEnrichment,
|
||||
[companyInfoEnrichment.id]: companyInfoEnrichment,
|
||||
}
|
||||
|
||||
/** All enrichments, in catalog order. */
|
||||
export const ALL_ENRICHMENTS: EnrichmentConfig[] = Object.values(ENRICHMENT_REGISTRY)
|
||||
|
||||
export function getEnrichment(id: string | undefined): EnrichmentConfig | undefined {
|
||||
return id ? ENRICHMENT_REGISTRY[id] : undefined
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockExecuteTool } = vi.hoisted(() => ({ mockExecuteTool: vi.fn() }))
|
||||
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
|
||||
|
||||
import { runEnrichment, skippedEnrichmentDetail } from '@/enrichments/run'
|
||||
import type { EnrichmentConfig, EnrichmentProvider } from '@/enrichments/types'
|
||||
|
||||
const ICON = (() => null) as unknown as EnrichmentConfig['icon']
|
||||
|
||||
function prov(
|
||||
id: string,
|
||||
opts: {
|
||||
build?: (inputs: Record<string, unknown>) => Record<string, unknown> | null
|
||||
map?: (output: Record<string, unknown>) => Record<string, unknown> | null
|
||||
} = {}
|
||||
): EnrichmentProvider {
|
||||
return {
|
||||
id,
|
||||
label: id.toUpperCase(),
|
||||
toolId: `tool_${id}`,
|
||||
buildParams: opts.build ?? (() => ({ q: 'x' })),
|
||||
mapOutput: opts.map ?? ((o) => (o.email ? { email: o.email } : null)),
|
||||
}
|
||||
}
|
||||
|
||||
function config(providers: EnrichmentProvider[]): EnrichmentConfig {
|
||||
return {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
icon: ICON,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = { workspaceId: 'ws-1' }
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteTool.mockReset()
|
||||
})
|
||||
|
||||
describe('runEnrichment cascade detail', () => {
|
||||
it('records the first match and stops the cascade', async () => {
|
||||
mockExecuteTool.mockImplementation((toolId: string) => {
|
||||
if (toolId === 'tool_a') return { success: false, output: { status: 404 } }
|
||||
if (toolId === 'tool_b')
|
||||
return { success: true, output: { email: 'j@acme.com', cost: { total: 0.05 } } }
|
||||
throw new Error('tool_c should never run after a match')
|
||||
})
|
||||
|
||||
const outcome = await runEnrichment(config([prov('a'), prov('b'), prov('c')]), {}, ctx)
|
||||
|
||||
expect(outcome.result).toEqual({ email: 'j@acme.com' })
|
||||
expect(outcome.cost).toBe(0.05)
|
||||
expect(outcome.error).toBeNull()
|
||||
expect(outcome.provider).toBe('B')
|
||||
|
||||
expect(outcome.detail.matchedProvider).toBe('b')
|
||||
expect(outcome.detail.totalCost).toBe(0.05)
|
||||
// The full cascade is recorded; the provider after the match is `not_run`.
|
||||
expect(outcome.detail.providers.map((p) => p.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(outcome.detail.providers.map((p) => p.status)).toEqual([
|
||||
'no_match',
|
||||
'matched',
|
||||
'not_run',
|
||||
])
|
||||
expect(outcome.detail.providers[1]?.cost).toBe(0.05)
|
||||
expect(outcome.detail.providers.every((p) => typeof p.durationMs === 'number')).toBe(true)
|
||||
// The tool is never called for the matched-past provider.
|
||||
expect(mockExecuteTool).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('marks providers with insufficient inputs as skipped without calling the tool', async () => {
|
||||
mockExecuteTool.mockImplementation(() => ({
|
||||
success: true,
|
||||
output: { email: 'j@acme.com' },
|
||||
}))
|
||||
|
||||
const outcome = await runEnrichment(
|
||||
config([prov('a', { build: () => null }), prov('b')]),
|
||||
{},
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(outcome.detail.providers[0]).toMatchObject({ id: 'a', status: 'skipped', durationMs: 0 })
|
||||
expect(outcome.detail.providers[1]?.status).toBe('matched')
|
||||
// Only provider b actually called the tool.
|
||||
expect(mockExecuteTool).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sets error only when every provider that ran errored', async () => {
|
||||
mockExecuteTool.mockImplementation(() => ({ success: false, output: { status: 500 } }))
|
||||
|
||||
const outcome = await runEnrichment(config([prov('a'), prov('b')]), {}, ctx)
|
||||
|
||||
expect(outcome.result).toEqual({})
|
||||
expect(outcome.error).not.toBeNull()
|
||||
expect(outcome.provider).toBeNull()
|
||||
expect(outcome.detail.matchedProvider).toBeNull()
|
||||
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error', 'error'])
|
||||
expect(outcome.detail.providers.every((p) => p.error)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a clean miss (ran, empty result) as no_match with no error', async () => {
|
||||
mockExecuteTool.mockImplementation(() => ({ success: true, output: {} }))
|
||||
|
||||
const outcome = await runEnrichment(config([prov('a')]), {}, ctx)
|
||||
|
||||
expect(outcome.result).toEqual({})
|
||||
expect(outcome.error).toBeNull()
|
||||
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['no_match'])
|
||||
})
|
||||
|
||||
it('skippedEnrichmentDetail marks every provider skipped without running', () => {
|
||||
const detail = skippedEnrichmentDetail(config([prov('a'), prov('b')]))
|
||||
expect(detail.matchedProvider).toBeNull()
|
||||
expect(detail.totalCost).toBe(0)
|
||||
expect(detail.providers.map((p) => p.status)).toEqual(['skipped', 'skipped'])
|
||||
expect(mockExecuteTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks unattempted providers not_run when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const outcome = await runEnrichment(
|
||||
config([prov('a'), prov('b')]),
|
||||
{},
|
||||
{
|
||||
...ctx,
|
||||
signal: controller.signal,
|
||||
}
|
||||
)
|
||||
expect(mockExecuteTool).not.toHaveBeenCalled()
|
||||
expect(outcome.detail.aborted).toBe(true)
|
||||
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['not_run', 'not_run'])
|
||||
})
|
||||
|
||||
it('does not error when some providers no-match and only some error', async () => {
|
||||
mockExecuteTool.mockImplementation((toolId: string) => {
|
||||
if (toolId === 'tool_a') return { success: false, output: { status: 500 } }
|
||||
return { success: false, output: { status: 404 } }
|
||||
})
|
||||
|
||||
const outcome = await runEnrichment(config([prov('a'), prov('b')]), {}, ctx)
|
||||
|
||||
expect(outcome.error).toBeNull()
|
||||
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error', 'no_match'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table/types'
|
||||
import type { EnrichmentConfig, EnrichmentRunContext } from '@/enrichments/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const logger = createLogger('Enrichments')
|
||||
|
||||
/** Outcome of running an enrichment's provider cascade for one row. */
|
||||
export interface EnrichmentRunOutcome {
|
||||
/** Mapped output values from the winning provider, or `{}` when none hit. */
|
||||
result: Record<string, unknown>
|
||||
/** Total hosted-key cost (USD) across providers that ran; `0` for BYOK / free. */
|
||||
cost: number
|
||||
/**
|
||||
* Set only when every provider that actually ran errored (none produced a
|
||||
* clean result or a clean miss). Lets the caller mark the cell errored instead
|
||||
* of blanking it — a genuine "no match" still leaves this `null`.
|
||||
*/
|
||||
error: string | null
|
||||
/** Label of the provider whose result was returned, or `null` on no match. */
|
||||
provider: string | null
|
||||
/** Per-provider cascade breakdown + timing for the enrichment details panel. */
|
||||
detail: EnrichmentRunDetail
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail for a terminal cell that recorded no provider attempt — missing
|
||||
* required inputs, or cancelled before any provider ran. Every provider is
|
||||
* marked `skipped` so the details panel stays informative (shows the configured
|
||||
* cascade) instead of empty.
|
||||
*/
|
||||
export function skippedEnrichmentDetail(
|
||||
enrichment: EnrichmentConfig,
|
||||
opts: { aborted?: boolean } = {}
|
||||
): EnrichmentRunDetail {
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
durationMs: 0,
|
||||
totalCost: 0,
|
||||
matchedProvider: null,
|
||||
aborted: opts.aborted ?? false,
|
||||
providers: enrichment.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
toolId: provider.toolId,
|
||||
status: 'skipped' as const,
|
||||
cost: 0,
|
||||
durationMs: 0,
|
||||
error: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** True when at least one output value in the result is non-empty. */
|
||||
function hasResult(result: Record<string, unknown>): boolean {
|
||||
return Object.values(result).some((v) => v !== undefined && v !== null && v !== '')
|
||||
}
|
||||
|
||||
/** Reads the hosted-key cost `executeTool` merges into a successful output. */
|
||||
function readCost(output: Record<string, unknown>): number {
|
||||
const total = (output.cost as { total?: unknown } | undefined)?.total
|
||||
return typeof total === 'number' && Number.isFinite(total) && total > 0 ? total : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an enrichment's provider cascade for one row. Tries providers in order;
|
||||
* the first that returns a non-empty result wins and is returned. A provider is
|
||||
* skipped when its `buildParams` returns `null` (insufficient inputs); a tool
|
||||
* failure or empty mapped result falls through to the next. When every provider
|
||||
* that ran errored, `error` is set so the caller can mark the cell errored; a
|
||||
* clean miss leaves `error: null` (blank cell). Hosted-key cost is accumulated
|
||||
* across providers for the caller to bill.
|
||||
*
|
||||
* Server-only: imports `executeTool`, which pulls in DB / mailer code. Only the
|
||||
* background cell executor imports this module (dynamically).
|
||||
*/
|
||||
export async function runEnrichment(
|
||||
enrichment: EnrichmentConfig,
|
||||
inputs: Record<string, unknown>,
|
||||
ctx: EnrichmentRunContext
|
||||
): Promise<EnrichmentRunOutcome> {
|
||||
let cost = 0
|
||||
let ranCount = 0
|
||||
let errorCount = 0
|
||||
let lastError: string | null = null
|
||||
let matchedProvider: string | null = null
|
||||
let winner: { result: Record<string, unknown>; label: string } | null = null
|
||||
const providers: EnrichmentProviderOutcome[] = []
|
||||
const startedAt = Date.now()
|
||||
|
||||
for (let i = 0; i < enrichment.providers.length; i++) {
|
||||
const provider = enrichment.providers[i]
|
||||
if (ctx.signal?.aborted) break
|
||||
const params = provider.buildParams(inputs)
|
||||
if (!params) {
|
||||
providers.push({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
toolId: provider.toolId,
|
||||
status: 'skipped',
|
||||
cost: 0,
|
||||
durationMs: 0,
|
||||
error: null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
ranCount++
|
||||
const providerStart = Date.now()
|
||||
try {
|
||||
const response = await executeTool(
|
||||
provider.toolId,
|
||||
{ ...params, _context: { workspaceId: ctx.workspaceId } },
|
||||
{ signal: ctx.signal }
|
||||
)
|
||||
if (!response.success) {
|
||||
// A 404 means the provider simply has no record for these inputs — a
|
||||
// clean no-match, not an infra failure. Fall through to the next
|
||||
// provider without counting it as an error (so the cell shows "Not
|
||||
// found" rather than an error). Other statuses (auth, rate-limit, 5xx)
|
||||
// are real errors and propagate.
|
||||
const status = (response.output as { status?: unknown } | undefined)?.status
|
||||
if (status === 404) {
|
||||
providers.push({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
toolId: provider.toolId,
|
||||
status: 'no_match',
|
||||
cost: 0,
|
||||
durationMs: Date.now() - providerStart,
|
||||
error: null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
throw new Error(response.error ?? `${provider.toolId} failed`)
|
||||
}
|
||||
const providerCost = readCost(response.output)
|
||||
cost += providerCost
|
||||
const result = provider.mapOutput(response.output)
|
||||
if (result && hasResult(result)) {
|
||||
providers.push({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
toolId: provider.toolId,
|
||||
status: 'matched',
|
||||
cost: providerCost,
|
||||
durationMs: Date.now() - providerStart,
|
||||
error: null,
|
||||
})
|
||||
matchedProvider = provider.id
|
||||
winner = { result, label: provider.label }
|
||||
logger.info('Enrichment hit', { enrichmentId: enrichment.id, provider: provider.id })
|
||||
break
|
||||
}
|
||||
// Ran cleanly but mapped to nothing — a no-match, fall through to the next.
|
||||
providers.push({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
toolId: provider.toolId,
|
||||
status: 'no_match',
|
||||
cost: providerCost,
|
||||
durationMs: Date.now() - providerStart,
|
||||
error: null,
|
||||
})
|
||||
} catch (err) {
|
||||
errorCount++
|
||||
lastError = getErrorMessage(err)
|
||||
providers.push({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
toolId: provider.toolId,
|
||||
status: 'error',
|
||||
cost: 0,
|
||||
durationMs: Date.now() - providerStart,
|
||||
error: lastError,
|
||||
})
|
||||
logger.warn('Enrichment provider failed; trying next', {
|
||||
enrichmentId: enrichment.id,
|
||||
provider: provider.id,
|
||||
error: lastError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Any provider not represented yet never ran — the cascade short-circuited on
|
||||
// a match or aborted mid-run. Record them as `not_run` (in registry order) so
|
||||
// the panel always shows the full configured cascade.
|
||||
const seen = new Set(providers.map((p) => p.id))
|
||||
for (const provider of enrichment.providers) {
|
||||
if (seen.has(provider.id)) continue
|
||||
providers.push({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
toolId: provider.toolId,
|
||||
status: 'not_run',
|
||||
cost: 0,
|
||||
durationMs: 0,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
const completedAt = Date.now()
|
||||
const detail: EnrichmentRunDetail = {
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
completedAt: new Date(completedAt).toISOString(),
|
||||
durationMs: completedAt - startedAt,
|
||||
totalCost: cost,
|
||||
matchedProvider,
|
||||
aborted: Boolean(ctx.signal?.aborted),
|
||||
providers,
|
||||
}
|
||||
|
||||
if (winner) {
|
||||
return { result: winner.result, cost, error: null, provider: winner.label, detail }
|
||||
}
|
||||
|
||||
// No provider hit. Surface an error only when every provider that ran errored
|
||||
// (infra/auth/rate-limit) — a clean miss returns a blank result instead.
|
||||
const error = ranCount > 0 && errorCount === ranCount ? lastError : null
|
||||
return { result: {}, cost, error, provider: null, detail }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type React from 'react'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
|
||||
/** One per-row input an enrichment needs. Mapped to a table column by the user. */
|
||||
export interface EnrichmentInputField {
|
||||
/** Stable key passed into `enrich()` (`inputs[id]`). */
|
||||
id: string
|
||||
/** Human label shown in the config panel. */
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean'
|
||||
required?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** One value an enrichment produces. Becomes a table column. */
|
||||
export interface EnrichmentOutputField {
|
||||
/** Key the value is returned under from a provider's `run()` (`result[id]`). */
|
||||
id: string
|
||||
/** Default column name. */
|
||||
name: string
|
||||
type: ColumnDefinition['type']
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution context for an enrichment run (runs server-side). `tableId`/`rowId`
|
||||
* are present for the table per-row path but optional — the workflow block path
|
||||
* (`/api/tools/enrichment/run`) has no table/row and passes only `workspaceId`.
|
||||
*/
|
||||
export interface EnrichmentRunContext {
|
||||
tableId?: string
|
||||
rowId?: string
|
||||
workspaceId: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* One data source an enrichment can try, described as plain data so the catalog
|
||||
* (which the table UI imports for metadata) never pulls in server-only tool
|
||||
* code. Providers are attempted in declared order (a fallback cascade); the
|
||||
* cascade runner (`run.ts`, server-only) calls the tool and the first provider
|
||||
* to return a non-empty result fills the cell.
|
||||
*/
|
||||
export interface EnrichmentProvider {
|
||||
/** Stable id for logs, e.g. `'hunter'`, `'pdl'`. */
|
||||
id: string
|
||||
/** Human label, e.g. `'Hunter'`, `'People Data Labs'`. */
|
||||
label: string
|
||||
/** Tool executed via `executeTool` (in the server-only runner). */
|
||||
toolId: string
|
||||
/**
|
||||
* Maps enrichment inputs to tool params, or `null` when there aren't enough
|
||||
* inputs to run this provider (cascade falls through to the next).
|
||||
*/
|
||||
buildParams: (inputs: Record<string, unknown>) => Record<string, unknown> | null
|
||||
/**
|
||||
* Maps the tool's output to `{ [outputId]: value }`, or `null` for no result.
|
||||
* An empty/`null` result falls through to the next provider.
|
||||
*/
|
||||
mapOutput: (output: Record<string, unknown>) => Record<string, unknown> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A code-defined enrichment. Runs directly per table row (no workflow): the
|
||||
* table's per-cell executor runs the provider cascade with the mapped inputs
|
||||
* and writes each returned output value into its column.
|
||||
*/
|
||||
export interface EnrichmentConfig {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
/** Shown in the catalog + (future) column header. */
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
inputs: EnrichmentInputField[]
|
||||
outputs: EnrichmentOutputField[]
|
||||
/** Data sources tried in order until one returns a non-empty result. */
|
||||
providers: EnrichmentProvider[]
|
||||
}
|
||||
|
||||
export type EnrichmentRegistry = Record<string, EnrichmentConfig>
|
||||
@@ -0,0 +1 @@
|
||||
export { workEmailEnrichment } from './work-email'
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { EnrichmentProvider } from '@/enrichments/types'
|
||||
import { workEmailEnrichment } from '@/enrichments/work-email/work-email'
|
||||
|
||||
function provider(id: string): EnrichmentProvider {
|
||||
const p = workEmailEnrichment.providers.find((x) => x.id === id)
|
||||
if (!p) throw new Error(`Provider ${id} not found in work-email cascade`)
|
||||
return p
|
||||
}
|
||||
|
||||
const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
|
||||
const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' }
|
||||
|
||||
describe('work-email enrichment cascade', () => {
|
||||
it('chains the hosted providers in waterfall order', () => {
|
||||
expect(workEmailEnrichment.providers.map((p) => p.id)).toEqual([
|
||||
'hunter',
|
||||
'findymail',
|
||||
'findymail-linkedin',
|
||||
'prospeo',
|
||||
'wiza',
|
||||
'pdl',
|
||||
'datagma',
|
||||
'leadmagic',
|
||||
'dropcontact',
|
||||
'enrow',
|
||||
])
|
||||
})
|
||||
|
||||
describe('findymail (name)', () => {
|
||||
const p = provider('findymail')
|
||||
it('maps name + domain and extracts contact.email', () => {
|
||||
expect(p.toolId).toBe('findymail_find_email_from_name')
|
||||
expect(p.buildParams(nameDomain)).toEqual({ name: 'John Doe', domain: 'acme.com' })
|
||||
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
|
||||
expect(p.buildParams(linkedinOnly)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findymail-linkedin', () => {
|
||||
const p = provider('findymail-linkedin')
|
||||
it('keys off the LinkedIn URL and skips without one', () => {
|
||||
expect(p.toolId).toBe('findymail_find_email_from_linkedin')
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({
|
||||
linkedin_url: 'https://linkedin.com/in/johndoe',
|
||||
})
|
||||
expect(p.buildParams(nameDomain)).toBeNull()
|
||||
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('prospeo (opportunistic)', () => {
|
||||
const p = provider('prospeo')
|
||||
it('uses name+domain, or LinkedIn when present', () => {
|
||||
expect(p.buildParams(nameDomain)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
company_website: 'acme.com',
|
||||
})
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
linkedin_url: 'https://linkedin.com/in/johndoe',
|
||||
})
|
||||
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
|
||||
expect(p.mapOutput({ person: { email: { email: 'j@acme.com' } } })).toEqual({
|
||||
email: 'j@acme.com',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('wiza (opportunistic)', () => {
|
||||
const p = provider('wiza')
|
||||
it('reveals email-only (partial), preferring LinkedIn profile_url', () => {
|
||||
expect(p.buildParams(nameDomain)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
domain: 'acme.com',
|
||||
enrichment_level: 'partial',
|
||||
})
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
profile_url: 'https://linkedin.com/in/johndoe',
|
||||
enrichment_level: 'partial',
|
||||
})
|
||||
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
|
||||
expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('datagma', () => {
|
||||
const p = provider('datagma')
|
||||
it('maps name + normalized company domain', () => {
|
||||
expect(p.toolId).toBe('datagma_find_email')
|
||||
expect(p.buildParams(nameDomain)).toEqual({ fullName: 'John Doe', company: 'acme.com' })
|
||||
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
|
||||
expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' })
|
||||
expect(p.mapOutput({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('leadmagic', () => {
|
||||
const p = provider('leadmagic')
|
||||
it('passes full_name + domain and keeps mononym rows', () => {
|
||||
expect(p.toolId).toBe('leadmagic_find_email')
|
||||
expect(p.buildParams(nameDomain)).toEqual({ full_name: 'John Doe', domain: 'acme.com' })
|
||||
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
|
||||
// single-token name still runs (no longer skipped)
|
||||
expect(p.buildParams({ fullName: 'Cher', companyDomain: 'acme.com' })).toEqual({
|
||||
full_name: 'Cher',
|
||||
domain: 'acme.com',
|
||||
})
|
||||
expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropcontact', () => {
|
||||
const p = provider('dropcontact')
|
||||
it('enriches from name plus company or LinkedIn', () => {
|
||||
expect(p.toolId).toBe('dropcontact_enrich_contact')
|
||||
expect(p.buildParams(nameDomain)).toEqual({ full_name: 'John Doe', website: 'acme.com' })
|
||||
expect(p.buildParams(linkedinOnly)).toEqual({
|
||||
full_name: 'John Doe',
|
||||
linkedin: 'https://linkedin.com/in/johndoe',
|
||||
})
|
||||
expect(p.buildParams({ companyDomain: 'acme.com' })).toBeNull()
|
||||
expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' })
|
||||
expect(p.mapOutput({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('enrow', () => {
|
||||
const p = provider('enrow')
|
||||
it('maps full name + company domain', () => {
|
||||
expect(p.toolId).toBe('enrow_find_email')
|
||||
expect(p.buildParams(nameDomain)).toEqual({
|
||||
fullname: 'John Doe',
|
||||
company_domain: 'acme.com',
|
||||
})
|
||||
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
|
||||
// only a valid-qualified email fills the cell
|
||||
expect(p.mapOutput({ email: 'j@acme.com', qualification: 'valid' })).toEqual({
|
||||
email: 'j@acme.com',
|
||||
})
|
||||
expect(p.mapOutput({ email: 'j@acme.com', qualification: 'invalid' })).toBeNull()
|
||||
expect(p.mapOutput({ email: 'j@acme.com' })).toBeNull()
|
||||
expect(p.mapOutput({})).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Mail } from '@sim/emcn/icons'
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers'
|
||||
import type { EnrichmentConfig } from '@/enrichments/types'
|
||||
|
||||
/**
|
||||
* Work Email enrichment. Finds a person's work email from a full name plus any
|
||||
* available identifiers (company domain, LinkedIn URL) via a provider waterfall:
|
||||
* deterministic finders first (Hunter, Findymail by name then by LinkedIn), then
|
||||
* enrichment/reveal providers (Prospeo, Wiza), then People Data Labs as a broad
|
||||
* record-match fallback, then Datagma, LeadMagic, Dropcontact, and Enrow
|
||||
* as additional finders. Each provider opportunistically uses whatever
|
||||
* identifiers the row provides and self-skips when it has none usable, so adding
|
||||
* more inputs widens coverage. First email wins; all providers support hosted keys.
|
||||
*/
|
||||
export const workEmailEnrichment: EnrichmentConfig = {
|
||||
id: 'work-email',
|
||||
name: 'Work Email',
|
||||
description: "Find a person's work email from their name, company, or LinkedIn URL.",
|
||||
icon: Mail,
|
||||
inputs: [
|
||||
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
|
||||
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
|
||||
{ id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' },
|
||||
],
|
||||
outputs: [{ id: 'email', name: 'email', type: 'string' }],
|
||||
providers: [
|
||||
toolProvider({
|
||||
id: 'hunter',
|
||||
label: 'Hunter',
|
||||
toolId: 'hunter_email_finder',
|
||||
buildParams: (inputs) => {
|
||||
const name = splitName(inputs.fullName)
|
||||
const domain = normalizeDomain(inputs.companyDomain)
|
||||
if (!name || !domain) return null
|
||||
return { domain, first_name: name.firstName, last_name: name.lastName }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const email = str(output.email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'findymail',
|
||||
label: 'Findymail',
|
||||
toolId: 'findymail_find_email_from_name',
|
||||
buildParams: (inputs) => {
|
||||
const name = str(inputs.fullName)
|
||||
const domain = normalizeDomain(inputs.companyDomain)
|
||||
if (!name || !domain) return null
|
||||
return { name, domain }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const contact = output.contact as Record<string, unknown> | null
|
||||
const email = str(contact?.email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'findymail-linkedin',
|
||||
label: 'Findymail (LinkedIn)',
|
||||
toolId: 'findymail_find_email_from_linkedin',
|
||||
buildParams: (inputs) => {
|
||||
const linkedin = str(inputs.linkedinUrl)
|
||||
if (!linkedin) return null
|
||||
return { linkedin_url: linkedin }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const contact = output.contact as Record<string, unknown> | null
|
||||
const email = str(contact?.email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'prospeo',
|
||||
label: 'Prospeo',
|
||||
toolId: 'prospeo_enrich_person',
|
||||
buildParams: (inputs) => {
|
||||
const linkedin = str(inputs.linkedinUrl)
|
||||
const fullName = str(inputs.fullName)
|
||||
const companyWebsite = normalizeDomain(inputs.companyDomain)
|
||||
if (!linkedin && !(fullName && companyWebsite)) return null
|
||||
return filterUndefined({
|
||||
linkedin_url: linkedin || undefined,
|
||||
full_name: fullName || undefined,
|
||||
company_website: companyWebsite || undefined,
|
||||
})
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const person = output.person as Record<string, unknown> | undefined
|
||||
const emailObj = person?.email as Record<string, unknown> | undefined
|
||||
const email = str(emailObj?.email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'wiza',
|
||||
label: 'Wiza',
|
||||
toolId: 'wiza_individual_reveal',
|
||||
buildParams: (inputs) => {
|
||||
const linkedin = str(inputs.linkedinUrl)
|
||||
const fullName = str(inputs.fullName)
|
||||
const domain = normalizeDomain(inputs.companyDomain)
|
||||
if (!linkedin && !(fullName && domain)) return null
|
||||
// 'partial' reveals the email only (2 credits); avoids phone charges.
|
||||
return filterUndefined({
|
||||
profile_url: linkedin || undefined,
|
||||
full_name: fullName || undefined,
|
||||
domain: domain || undefined,
|
||||
enrichment_level: 'partial',
|
||||
})
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const email = str(output.email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'pdl',
|
||||
label: 'People Data Labs',
|
||||
toolId: 'pdl_person_enrich',
|
||||
buildParams: (inputs) => {
|
||||
const name = str(inputs.fullName)
|
||||
if (!name) return null
|
||||
// `required` makes PDL 404 (free) when the profile has no work email,
|
||||
// instead of charging a credit for a match we'd discard as a no-match.
|
||||
return filterUndefined({
|
||||
name,
|
||||
company: normalizeDomain(inputs.companyDomain) || undefined,
|
||||
min_likelihood: 6,
|
||||
required: 'work_email',
|
||||
})
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const person = output.person as Record<string, unknown> | undefined
|
||||
const email = str(person?.work_email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'datagma',
|
||||
label: 'Datagma',
|
||||
toolId: 'datagma_find_email',
|
||||
buildParams: (inputs) => {
|
||||
const fullName = str(inputs.fullName)
|
||||
const company = normalizeDomain(inputs.companyDomain)
|
||||
if (!fullName || !company) return null
|
||||
return { fullName, company }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const email = str(output.email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'leadmagic',
|
||||
label: 'LeadMagic',
|
||||
toolId: 'leadmagic_find_email',
|
||||
buildParams: (inputs) => {
|
||||
// LeadMagic accepts full_name + domain, so pass the whole name and let it
|
||||
// split — this keeps single-token (mononym) rows in play.
|
||||
const fullName = str(inputs.fullName)
|
||||
const domain = normalizeDomain(inputs.companyDomain)
|
||||
if (!fullName || !domain) return null
|
||||
return { full_name: fullName, domain }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const email = str(output.email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'dropcontact',
|
||||
label: 'Dropcontact',
|
||||
toolId: 'dropcontact_enrich_contact',
|
||||
buildParams: (inputs) => {
|
||||
const fullName = str(inputs.fullName)
|
||||
const website = normalizeDomain(inputs.companyDomain)
|
||||
const linkedin = str(inputs.linkedinUrl)
|
||||
if (!fullName || (!website && !linkedin)) return null
|
||||
return filterUndefined({
|
||||
full_name: fullName,
|
||||
website: website || undefined,
|
||||
linkedin: linkedin || undefined,
|
||||
})
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
const email = str(output.email)
|
||||
return email ? { email } : null
|
||||
},
|
||||
}),
|
||||
toolProvider({
|
||||
id: 'enrow',
|
||||
label: 'Enrow',
|
||||
toolId: 'enrow_find_email',
|
||||
buildParams: (inputs) => {
|
||||
const fullname = str(inputs.fullName)
|
||||
const company_domain = normalizeDomain(inputs.companyDomain)
|
||||
if (!fullname || !company_domain) return null
|
||||
return { fullname, company_domain }
|
||||
},
|
||||
mapOutput: (output) => {
|
||||
// Enrow qualifies each found email valid/invalid; only accept verified-valid
|
||||
// results so the cell isn't filled with an address Enrow itself rejected
|
||||
// (and which hosted billing correctly charges zero for).
|
||||
const email = str(output.email)
|
||||
const qualification = str(output.qualification).toLowerCase()
|
||||
return email && qualification === 'valid' ? { email } : null
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user