From 922bd784ab23b756ccc534248dff3a5b515f0a5c Mon Sep 17 00:00:00 2001 From: Jack <72348727+Jack-GitHub12@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:38:39 -0500 Subject: [PATCH] feat(apollo): add credential definition and 3 enrichment operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Apollo integration per issue #17. New operations: - apolloEnrichPersonNode — POST /api/v1/people/match by email - apolloEnrichCompanyNode — GET /api/v1/organizations/enrich by domain - apolloGetEmailStatusNode — reuses people/match to derive a normalized valid/invalid/unknown verdict via the mapApolloEmailStatus helper Also adds apolloCredential built with defineApiKeyCredential (X-Api-Key header) and wires the new nodes + credential through every barrel (apollo, integrations, top-level src/index.ts) including the builtInNodes array. Fixes an unrelated pre-existing bug in google-sheets/googleSheets.ts that was importing defineOAuth2Credential from a non-existent '../types/credentials.js' path — corrected to '@jam-nodes/core'. Without this fix, anything that loads packages/nodes/src/index.ts (including the new apollo integration test that asserts builtInNodes contains the new nodes) fails to resolve modules. 39 new unit tests cover: credential metadata, schema validation, success paths, null/missing-field handling, 4xx/5xx responses, network errors, and the full status mapping matrix. --- packages/nodes/src/index.ts | 17 + .../apollo/__tests__/apollo.test.ts | 719 ++++++++++++++++++ .../src/integrations/apollo/credentials.ts | 17 + .../src/integrations/apollo/enrich-company.ts | 143 ++++ .../src/integrations/apollo/enrich-person.ts | 165 ++++ .../integrations/apollo/get-email-status.ts | 145 ++++ .../nodes/src/integrations/apollo/index.ts | 33 + .../google-sheets/googleSheets.ts | 2 +- packages/nodes/src/integrations/index.ts | 23 + 9 files changed, 1263 insertions(+), 1 deletion(-) create mode 100644 packages/nodes/src/integrations/apollo/__tests__/apollo.test.ts create mode 100644 packages/nodes/src/integrations/apollo/credentials.ts create mode 100644 packages/nodes/src/integrations/apollo/enrich-company.ts create mode 100644 packages/nodes/src/integrations/apollo/enrich-person.ts create mode 100644 packages/nodes/src/integrations/apollo/get-email-status.ts diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 97e62dc..3d58415 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -132,6 +132,17 @@ export { searchContactsNode, SearchContactsInputSchema, SearchContactsOutputSchema, + apolloCredential, + apolloEnrichPersonNode, + ApolloEnrichPersonInputSchema, + ApolloEnrichPersonOutputSchema, + apolloEnrichCompanyNode, + ApolloEnrichCompanyInputSchema, + ApolloEnrichCompanyOutputSchema, + apolloGetEmailStatusNode, + ApolloGetEmailStatusInputSchema, + ApolloGetEmailStatusOutputSchema, + mapApolloEmailStatus, // Discord discordSendMessageNode, DiscordSendMessageInputSchema, @@ -331,6 +342,9 @@ import { dataforseoPeopleAlsoAskNode, dataforseoSerpNode, searchContactsNode, + apolloEnrichPersonNode, + apolloEnrichCompanyNode, + apolloGetEmailStatusNode, discordSendMessageNode, discordSendWebhookNode, discordCreateThreadNode, @@ -389,6 +403,9 @@ export const builtInNodes = [ dataforseoPeopleAlsoAskNode, dataforseoSerpNode, searchContactsNode, + apolloEnrichPersonNode, + apolloEnrichCompanyNode, + apolloGetEmailStatusNode, discordSendMessageNode, discordSendWebhookNode, discordCreateThreadNode, diff --git a/packages/nodes/src/integrations/apollo/__tests__/apollo.test.ts b/packages/nodes/src/integrations/apollo/__tests__/apollo.test.ts new file mode 100644 index 0000000..110416c --- /dev/null +++ b/packages/nodes/src/integrations/apollo/__tests__/apollo.test.ts @@ -0,0 +1,719 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + apolloCredential, + apolloEnrichPersonNode, + apolloEnrichCompanyNode, + apolloGetEmailStatusNode, + ApolloEnrichPersonInputSchema, + ApolloEnrichCompanyInputSchema, + ApolloGetEmailStatusInputSchema, + mapApolloEmailStatus, +} from '../index.js' +import { builtInNodes } from '../../../index.js' + +const mockContext = { + userId: 'u1', + workflowExecutionId: 'w1', + variables: {}, + resolveNestedPath: () => undefined, + credentials: { apollo: { apiKey: 'test-api-key' } }, +} as const + +const mockContextNoCreds = { + userId: 'u1', + workflowExecutionId: 'w1', + variables: {}, + resolveNestedPath: () => undefined, +} as const + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +// ─── apolloCredential ──────────────────────────────────────────────────────── + +describe('apolloCredential', () => { + it('defines credential metadata', () => { + expect(apolloCredential.name).toBe('apollo') + expect(apolloCredential.type).toBe('apiKey') + expect(apolloCredential.displayName).toBe('Apollo.io API Key') + }) + + it('uses X-Api-Key header with apiKey template', () => { + expect(apolloCredential.authenticate.type).toBe('header') + expect( + (apolloCredential.authenticate as { properties: Record }) + .properties['X-Api-Key'], + ).toBe('{{apiKey}}') + }) + + it('schema accepts a string apiKey', () => { + const result = apolloCredential.schema.safeParse({ apiKey: 'abc' }) + expect(result.success).toBe(true) + }) + + it('schema rejects missing apiKey', () => { + const result = apolloCredential.schema.safeParse({}) + expect(result.success).toBe(false) + }) +}) + +// ─── apolloEnrichPersonNode ────────────────────────────────────────────────── + +describe('apollo_enrich_person', () => { + const samplePerson = { + id: 'p_123', + name: 'Jane Doe', + first_name: 'Jane', + last_name: 'Doe', + email: 'jane@example.com', + title: 'VP Engineering', + linkedin_url: 'https://linkedin.com/in/jane', + email_status: 'verified', + organization: { + name: 'Acme Corp', + }, + city: 'San Francisco', + state: 'CA', + country: 'United States', + } + + it('fails when API key is missing', async () => { + const result = await apolloEnrichPersonNode.executor( + { email: 'jane@example.com' }, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + expect(result.error).toContain('apollo.apiKey') + }) + + it('sends POST to /api/v1/people/match with email and X-Api-Key header', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ person: samplePerson }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + await apolloEnrichPersonNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/v1/people/match') + expect(init.method).toBe('POST') + const headers = init.headers as Record + expect(headers['X-Api-Key']).toBe('test-api-key') + expect(headers['Content-Type']).toBe('application/json') + const body = JSON.parse(String(init.body)) + expect(body.email).toBe('jane@example.com') + }) + + it('sends reveal_personal_emails: true in request body', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ person: samplePerson }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + await apolloEnrichPersonNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(String(init.body)) + expect(body.reveal_personal_emails).toBe(true) + }) + + it('returns normalized person on 200', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ person: samplePerson }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichPersonNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + const person = result.output?.person + expect(person).not.toBeNull() + expect(person?.id).toBe('p_123') + expect(person?.firstName).toBe('Jane') + expect(person?.lastName).toBe('Doe') + expect(person?.email).toBe('jane@example.com') + expect(person?.title).toBe('VP Engineering') + expect(person?.company).toBe('Acme Corp') + expect(person?.linkedinUrl).toBe('https://linkedin.com/in/jane') + expect(person?.city).toBe('San Francisco') + expect(person?.state).toBe('CA') + expect(person?.country).toBe('United States') + } + }) + + it('handles null organization on the person object', async () => { + const personWithoutOrg = { ...samplePerson, organization: null } + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ person: personWithoutOrg }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichPersonNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.person?.company).toBeNull() + } + }) + + it('handles missing nested fields', async () => { + const sparsePerson = { + id: 'p_456', + first_name: 'Bob', + } + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ person: sparsePerson }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichPersonNode.executor( + { email: 'bob@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + const person = result.output?.person + expect(person?.id).toBe('p_456') + expect(person?.firstName).toBe('Bob') + expect(person?.lastName).toBeNull() + expect(person?.email).toBeNull() + expect(person?.title).toBeNull() + expect(person?.company).toBeNull() + expect(person?.linkedinUrl).toBeNull() + expect(person?.city).toBeNull() + expect(person?.state).toBeNull() + expect(person?.country).toBeNull() + } + }) + + it('returns { person: null } when Apollo returns no match', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ person: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichPersonNode.executor( + { email: 'nobody@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.person).toBeNull() + } + }) + + it('returns { person: null } when Apollo returns an empty body', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichPersonNode.executor( + { email: 'nobody@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.person).toBeNull() + } + }) + + it('returns failure with status code when Apollo returns 500', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('internal server error', { status: 500 }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichPersonNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('500') + }) + + it('returns failure when fetch throws a network error', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNRESET')) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichPersonNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(false) + expect(typeof result.error).toBe('string') + expect(result.error?.length ?? 0).toBeGreaterThan(0) + }) + + it('rejects invalid email input via schema', () => { + const result = ApolloEnrichPersonInputSchema.safeParse({ + email: 'not-an-email', + }) + expect(result.success).toBe(false) + }) + + it('rejects empty input via schema', () => { + const result = ApolloEnrichPersonInputSchema.safeParse({}) + expect(result.success).toBe(false) + }) +}) + +// ─── apolloEnrichCompanyNode ───────────────────────────────────────────────── + +describe('apollo_enrich_company', () => { + const sampleOrg = { + id: 'o_42', + name: 'Acme Corp', + website_url: 'https://acme.example.com', + linkedin_url: 'https://linkedin.com/company/acme', + industry: 'Software', + estimated_num_employees: 250, + founded_year: 2010, + } + + it('fails when API key is missing', async () => { + const result = await apolloEnrichCompanyNode.executor( + { domain: 'acme.example.com' }, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + expect(result.error).toContain('apollo.apiKey') + }) + + it('sends request to /organizations/enrich with domain and X-Api-Key header', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ organization: sampleOrg }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + await apolloEnrichCompanyNode.executor( + { domain: 'acme.example.com' }, + mockContext, + ) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/v1/organizations/enrich') + expect(url).toContain('domain=acme.example.com') + const headers = init.headers as Record + expect(headers['X-Api-Key']).toBe('test-api-key') + }) + + it('returns normalized organization on 200', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ organization: sampleOrg }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichCompanyNode.executor( + { domain: 'acme.example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + const org = result.output?.organization + expect(org).not.toBeNull() + expect(org?.id).toBe('o_42') + expect(org?.name).toBe('Acme Corp') + expect(org?.websiteUrl).toBe('https://acme.example.com') + expect(org?.linkedinUrl).toBe('https://linkedin.com/company/acme') + expect(org?.industry).toBe('Software') + expect(org?.estimatedNumEmployees).toBe(250) + expect(org?.foundedYear).toBe(2010) + } + }) + + it('handles missing nested fields', async () => { + const sparseOrg = { id: 'o_99', name: 'Minimal Co' } + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ organization: sparseOrg }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichCompanyNode.executor( + { domain: 'minimal.example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + const org = result.output?.organization + expect(org?.id).toBe('o_99') + expect(org?.name).toBe('Minimal Co') + expect(org?.websiteUrl).toBeNull() + expect(org?.linkedinUrl).toBeNull() + expect(org?.industry).toBeNull() + expect(org?.estimatedNumEmployees).toBeNull() + expect(org?.foundedYear).toBeNull() + } + }) + + it('returns { organization: null } when Apollo returns no match', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ organization: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichCompanyNode.executor( + { domain: 'nowhere.example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.organization).toBeNull() + } + }) + + it('returns { organization: null } when Apollo returns an empty body', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichCompanyNode.executor( + { domain: 'nowhere.example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.organization).toBeNull() + } + }) + + it('returns failure with status on 4xx', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('not found', { status: 404 }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichCompanyNode.executor( + { domain: 'acme.example.com' }, + mockContext, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('404') + }) + + it('returns failure when fetch throws a network error', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('ENETUNREACH')) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloEnrichCompanyNode.executor( + { domain: 'acme.example.com' }, + mockContext, + ) + + expect(result.success).toBe(false) + expect(typeof result.error).toBe('string') + expect(result.error?.length ?? 0).toBeGreaterThan(0) + }) + + it('rejects empty domain via schema', () => { + const result = ApolloEnrichCompanyInputSchema.safeParse({ domain: '' }) + expect(result.success).toBe(false) + }) +}) + +// ─── apolloGetEmailStatusNode ──────────────────────────────────────────────── + +describe('apollo_get_email_status', () => { + it('fails when API key is missing', async () => { + const result = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContextNoCreds, + ) + expect(result.success).toBe(false) + expect(result.error).toContain('apollo.apiKey') + }) + + it('maps Apollo "verified" status to valid', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + person: { email_status: 'verified' }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.status).toBe('valid') + expect(result.output?.deliverability).toBe('verified') + } + }) + + it('maps Apollo "unverified" status to invalid', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ person: { email_status: 'unverified' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.status).toBe('invalid') + expect(result.output?.deliverability).toBe('unverified') + } + }) + + it('maps Apollo "likely_to_bounce" status to invalid', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ person: { email_status: 'likely_to_bounce' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.status).toBe('invalid') + } + }) + + it('maps unknown Apollo status to unknown', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ person: { email_status: 'catch-all' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.status).toBe('unknown') + expect(result.output?.deliverability).toBe('catch-all') + } + }) + + it('maps empty or missing email_status to unknown', async () => { + const fetchMockEmpty = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ person: { email_status: '' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMockEmpty) + + const result1 = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + expect(result1.success).toBe(true) + if (result1.success) { + expect(result1.output?.status).toBe('unknown') + } + + const fetchMockMissing = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ person: { id: 'p1' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMockMissing) + + const result2 = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + expect(result2.success).toBe(true) + if (result2.success) { + expect(result2.output?.status).toBe('unknown') + expect(result2.output?.deliverability).toBeNull() + } + }) + + it('returns unknown with null deliverability when Apollo returns { person: null }', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ person: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloGetEmailStatusNode.executor( + { email: 'nobody@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.status).toBe('unknown') + expect(result.output?.deliverability).toBeNull() + } + }) + + it('returns unknown with null deliverability when Apollo returns an empty body', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloGetEmailStatusNode.executor( + { email: 'nobody@example.com' }, + mockContext, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.output?.status).toBe('unknown') + expect(result.output?.deliverability).toBeNull() + } + }) + + it('returns failure when Apollo returns 500', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('server error', { status: 500 }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('500') + }) + + it('returns failure when fetch throws a network error', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('ETIMEDOUT')) + vi.stubGlobal('fetch', fetchMock) + + const result = await apolloGetEmailStatusNode.executor( + { email: 'jane@example.com' }, + mockContext, + ) + + expect(result.success).toBe(false) + expect(typeof result.error).toBe('string') + expect(result.error?.length ?? 0).toBeGreaterThan(0) + }) + + it('rejects invalid email input via schema', () => { + const result = ApolloGetEmailStatusInputSchema.safeParse({ + email: 'not-an-email', + }) + expect(result.success).toBe(false) + }) + + it('status mapping helper is exported and unit-testable', () => { + expect(mapApolloEmailStatus('verified')).toBe('valid') + expect(mapApolloEmailStatus('unverified')).toBe('invalid') + expect(mapApolloEmailStatus('likely_to_bounce')).toBe('invalid') + expect(mapApolloEmailStatus('bounced')).toBe('invalid') + expect(mapApolloEmailStatus('catch-all')).toBe('unknown') + expect(mapApolloEmailStatus('')).toBe('unknown') + expect(mapApolloEmailStatus(null)).toBe('unknown') + expect(mapApolloEmailStatus(undefined)).toBe('unknown') + }) +}) + +// ─── Integration / barrel exports ──────────────────────────────────────────── + +describe('apollo integration exports', () => { + it('exports the credential and three new nodes from the apollo barrel', () => { + expect(apolloCredential).toBeDefined() + expect(apolloEnrichPersonNode).toBeDefined() + expect(apolloEnrichCompanyNode).toBeDefined() + expect(apolloGetEmailStatusNode).toBeDefined() + expect(apolloEnrichPersonNode.type).toBe('apollo_enrich_person') + expect(apolloEnrichCompanyNode.type).toBe('apollo_enrich_company') + expect(apolloGetEmailStatusNode.type).toBe('apollo_get_email_status') + }) + + it('builtInNodes includes the three new Apollo nodes', () => { + const types = builtInNodes.map((n) => n.type) + expect(types).toContain('apollo_enrich_person') + expect(types).toContain('apollo_enrich_company') + expect(types).toContain('apollo_get_email_status') + }) +}) diff --git a/packages/nodes/src/integrations/apollo/credentials.ts b/packages/nodes/src/integrations/apollo/credentials.ts new file mode 100644 index 0000000..be1b57a --- /dev/null +++ b/packages/nodes/src/integrations/apollo/credentials.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' +import { defineApiKeyCredential } from '@jam-nodes/core' + +export const apolloCredential = defineApiKeyCredential({ + name: 'apollo', + displayName: 'Apollo.io API Key', + documentationUrl: 'https://apolloio.github.io/apollo-api-docs/', + schema: z.object({ + apiKey: z.string(), + }), + authenticate: { + type: 'header', + properties: { + 'X-Api-Key': '{{apiKey}}', + }, + }, +}) diff --git a/packages/nodes/src/integrations/apollo/enrich-company.ts b/packages/nodes/src/integrations/apollo/enrich-company.ts new file mode 100644 index 0000000..b2908f6 --- /dev/null +++ b/packages/nodes/src/integrations/apollo/enrich-company.ts @@ -0,0 +1,143 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; + +const APOLLO_API_BASE = 'https://api.apollo.io/api/v1'; + +// ============================================================================= +// Apollo API types +// ============================================================================= + +interface ApolloOrganization { + id?: string; + name?: string | null; + website_url?: string | null; + linkedin_url?: string | null; + industry?: string | null; + estimated_num_employees?: number | null; + founded_year?: number | null; + primary_domain?: string | null; +} + +interface ApolloOrganizationResponse { + organization?: ApolloOrganization | null; +} + +// ============================================================================= +// Schemas +// ============================================================================= + +export const ApolloEnrichCompanyInputSchema = z.object({ + domain: z.string().min(1), +}); + +export type ApolloEnrichCompanyInput = z.infer; + +export const ApolloEnrichedOrganizationSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + websiteUrl: z.string().nullable(), + linkedinUrl: z.string().nullable(), + industry: z.string().nullable(), + estimatedNumEmployees: z.number().nullable(), + foundedYear: z.number().nullable(), + primaryDomain: z.string().nullable(), +}); + +export type ApolloEnrichedOrganization = z.infer< + typeof ApolloEnrichedOrganizationSchema +>; + +export const ApolloEnrichCompanyOutputSchema = z.object({ + organization: ApolloEnrichedOrganizationSchema.nullable(), +}); + +export type ApolloEnrichCompanyOutput = z.infer< + typeof ApolloEnrichCompanyOutputSchema +>; + +// ============================================================================= +// Mapping helper +// ============================================================================= + +function normalizeOrganization(raw: ApolloOrganization): ApolloEnrichedOrganization { + return { + id: raw.id ?? '', + name: raw.name ?? null, + websiteUrl: raw.website_url ?? null, + linkedinUrl: raw.linkedin_url ?? null, + industry: raw.industry ?? null, + estimatedNumEmployees: raw.estimated_num_employees ?? null, + foundedYear: raw.founded_year ?? null, + primaryDomain: raw.primary_domain ?? null, + }; +} + +// ============================================================================= +// Node definition +// ============================================================================= + +export const apolloEnrichCompanyNode = defineNode({ + type: 'apollo_enrich_company', + name: 'Apollo Enrich Company', + description: 'Enrich a company by domain using Apollo.io Organization Enrichment', + category: 'integration', + inputSchema: ApolloEnrichCompanyInputSchema, + outputSchema: ApolloEnrichCompanyOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsEnrichment: true, + supportsRerun: true, + }, + + executor: async (input, context) => { + try { + const apiKey = context.credentials?.apollo?.apiKey; + if (!apiKey) { + return { + success: false, + error: + 'Apollo API key not configured. Please provide context.credentials.apollo.apiKey.', + }; + } + + const url = `${APOLLO_API_BASE}/organizations/enrich?domain=${encodeURIComponent(input.domain)}`; + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'X-Api-Key': apiKey, + }, + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Apollo API error: ${response.status} - ${errorText}`, + }; + } + + const data: ApolloOrganizationResponse = await response.json(); + const raw = data.organization; + if (!raw) { + return { success: true, output: { organization: null } }; + } + + return { + success: true, + output: { organization: normalizeOrganization(raw) }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to enrich company', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/apollo/enrich-person.ts b/packages/nodes/src/integrations/apollo/enrich-person.ts new file mode 100644 index 0000000..e9af227 --- /dev/null +++ b/packages/nodes/src/integrations/apollo/enrich-person.ts @@ -0,0 +1,165 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; + +const APOLLO_API_BASE = 'https://api.apollo.io/api/v1'; + +// ============================================================================= +// Apollo API types (minimal projection of the `people/match` response) +// ============================================================================= + +interface ApolloMatchedPerson { + id?: string; + name?: string | null; + first_name?: string | null; + last_name?: string | null; + email?: string | null; + title?: string | null; + linkedin_url?: string | null; + email_status?: string | null; + organization?: { + name?: string | null; + } | null; + organization_name?: string | null; + city?: string | null; + state?: string | null; + country?: string | null; +} + +interface ApolloMatchResponse { + person?: ApolloMatchedPerson | null; +} + +// ============================================================================= +// Schemas +// ============================================================================= + +export const ApolloEnrichPersonInputSchema = z.object({ + email: z.string().email(), +}); + +export type ApolloEnrichPersonInput = z.infer; + +export const ApolloEnrichedPersonSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + email: z.string().nullable(), + title: z.string().nullable(), + company: z.string().nullable(), + linkedinUrl: z.string().nullable(), + emailStatus: z.string().nullable(), + city: z.string().nullable(), + state: z.string().nullable(), + country: z.string().nullable(), +}); + +export type ApolloEnrichedPerson = z.infer; + +export const ApolloEnrichPersonOutputSchema = z.object({ + person: ApolloEnrichedPersonSchema.nullable(), +}); + +export type ApolloEnrichPersonOutput = z.infer; + +// ============================================================================= +// Mapping helper +// ============================================================================= + +function normalizePerson(raw: ApolloMatchedPerson): ApolloEnrichedPerson { + const company = raw.organization?.name ?? raw.organization_name ?? null; + const fullName = + raw.name ?? + ([raw.first_name, raw.last_name].filter(Boolean).join(' ').trim() || null); + + return { + id: raw.id ?? '', + name: fullName, + firstName: raw.first_name ?? null, + lastName: raw.last_name ?? null, + email: raw.email ?? null, + title: raw.title ?? null, + company, + linkedinUrl: raw.linkedin_url ?? null, + emailStatus: raw.email_status ?? null, + city: raw.city ?? null, + state: raw.state ?? null, + country: raw.country ?? null, + }; +} + +// ============================================================================= +// Node definition +// ============================================================================= + +/** + * Enrich a person by email address via Apollo.io's `people/match` endpoint. + * + * Sets `reveal_personal_emails: true` so the response includes the email field + * reliably. On some Apollo plans this flag bills additional credits per call. + */ +export const apolloEnrichPersonNode = defineNode({ + type: 'apollo_enrich_person', + name: 'Apollo Enrich Person', + description: 'Enrich a person by email using Apollo.io People Match', + category: 'integration', + inputSchema: ApolloEnrichPersonInputSchema, + outputSchema: ApolloEnrichPersonOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsEnrichment: true, + supportsRerun: true, + }, + + executor: async (input, context) => { + try { + const apiKey = context.credentials?.apollo?.apiKey; + if (!apiKey) { + return { + success: false, + error: + 'Apollo API key not configured. Please provide context.credentials.apollo.apiKey.', + }; + } + + const response = await fetchWithRetry( + `${APOLLO_API_BASE}/people/match`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'X-Api-Key': apiKey, + }, + body: JSON.stringify({ + email: input.email, + reveal_personal_emails: true, + }), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Apollo API error: ${response.status} - ${errorText}`, + }; + } + + const data: ApolloMatchResponse = await response.json(); + const raw = data.person; + if (!raw) { + return { success: true, output: { person: null } }; + } + + return { success: true, output: { person: normalizePerson(raw) } }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to enrich person', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/apollo/get-email-status.ts b/packages/nodes/src/integrations/apollo/get-email-status.ts new file mode 100644 index 0000000..a8ccdaa --- /dev/null +++ b/packages/nodes/src/integrations/apollo/get-email-status.ts @@ -0,0 +1,145 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; + +const APOLLO_API_BASE = 'https://api.apollo.io/api/v1'; + +// ============================================================================= +// Schemas +// ============================================================================= + +export const ApolloGetEmailStatusInputSchema = z.object({ + email: z.string().email(), +}); + +export type ApolloGetEmailStatusInput = z.infer< + typeof ApolloGetEmailStatusInputSchema +>; + +export const ApolloEmailStatusSchema = z.enum(['valid', 'invalid', 'unknown']); +export type ApolloEmailStatus = z.infer; + +export const ApolloGetEmailStatusOutputSchema = z.object({ + status: ApolloEmailStatusSchema, + deliverability: z.string().nullable(), +}); + +export type ApolloGetEmailStatusOutput = z.infer< + typeof ApolloGetEmailStatusOutputSchema +>; + +// ============================================================================= +// Status mapping helper (exported for direct unit testing) +// ============================================================================= + +/** + * Map an Apollo `email_status` value to a normalized deliverability verdict. + * + * Apollo returns strings like `"verified"`, `"unverified"`, `"likely_to_bounce"`, + * `"bounced"`, `"catch-all"`, etc. Anything we do not explicitly recognize as + * valid or invalid defaults to `"unknown"` so callers never see a surprising + * verdict. + */ +export function mapApolloEmailStatus( + raw: string | null | undefined, +): ApolloEmailStatus { + if (!raw) return 'unknown'; + const value = raw.toLowerCase(); + if (value === 'verified') return 'valid'; + if (value === 'unverified' || value === 'invalid') return 'invalid'; + if (value === 'likely_to_bounce' || value === 'bounced') return 'invalid'; + return 'unknown'; +} + +// ============================================================================= +// Apollo API types +// ============================================================================= + +interface ApolloMatchedPersonStatus { + email_status?: string | null; +} + +interface ApolloMatchResponse { + person?: ApolloMatchedPersonStatus | null; +} + +// ============================================================================= +// Node definition +// ============================================================================= + +/** + * Get Apollo.io email deliverability verdict for an email address. + * + * Implementation note: Apollo's dedicated email-verification endpoint is gated + * on some plans. This node reuses `POST /people/match` (the same endpoint used + * by `apolloEnrichPersonNode`) and maps `person.email_status` to a normalized + * verdict. If a workflow needs both the full person object and the status, + * prefer calling `apolloEnrichPersonNode` directly and reading `emailStatus` + * from its output — a single request will cover both needs. + */ +export const apolloGetEmailStatusNode = defineNode({ + type: 'apollo_get_email_status', + name: 'Apollo Get Email Status', + description: 'Get Apollo.io deliverability verdict for an email address', + category: 'integration', + inputSchema: ApolloGetEmailStatusInputSchema, + outputSchema: ApolloGetEmailStatusOutputSchema, + estimatedDuration: 2, + capabilities: { + supportsRerun: true, + }, + + executor: async (input, context) => { + try { + const apiKey = context.credentials?.apollo?.apiKey; + if (!apiKey) { + return { + success: false, + error: + 'Apollo API key not configured. Please provide context.credentials.apollo.apiKey.', + }; + } + + const response = await fetchWithRetry( + `${APOLLO_API_BASE}/people/match`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'X-Api-Key': apiKey, + }, + body: JSON.stringify({ + email: input.email, + reveal_personal_emails: true, + }), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }, + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Apollo API error: ${response.status} - ${errorText}`, + }; + } + + const data: ApolloMatchResponse = await response.json(); + const rawStatus = data.person?.email_status ?? null; + const status = mapApolloEmailStatus(rawStatus); + const deliverability = rawStatus && rawStatus.length > 0 ? rawStatus : null; + + return { + success: true, + output: { status, deliverability }, + }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : 'Failed to get email status', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/apollo/index.ts b/packages/nodes/src/integrations/apollo/index.ts index 9c65e48..e7a7570 100644 --- a/packages/nodes/src/integrations/apollo/index.ts +++ b/packages/nodes/src/integrations/apollo/index.ts @@ -5,3 +5,36 @@ export { type SearchContactsInput, type SearchContactsOutput, } from './search-contacts.js'; + +export { apolloCredential } from './credentials.js'; + +export { + apolloEnrichPersonNode, + ApolloEnrichPersonInputSchema, + ApolloEnrichPersonOutputSchema, + ApolloEnrichedPersonSchema, + type ApolloEnrichPersonInput, + type ApolloEnrichPersonOutput, + type ApolloEnrichedPerson, +} from './enrich-person.js'; + +export { + apolloEnrichCompanyNode, + ApolloEnrichCompanyInputSchema, + ApolloEnrichCompanyOutputSchema, + ApolloEnrichedOrganizationSchema, + type ApolloEnrichCompanyInput, + type ApolloEnrichCompanyOutput, + type ApolloEnrichedOrganization, +} from './enrich-company.js'; + +export { + apolloGetEmailStatusNode, + ApolloGetEmailStatusInputSchema, + ApolloGetEmailStatusOutputSchema, + ApolloEmailStatusSchema, + mapApolloEmailStatus, + type ApolloGetEmailStatusInput, + type ApolloGetEmailStatusOutput, + type ApolloEmailStatus, +} from './get-email-status.js'; diff --git a/packages/nodes/src/integrations/google-sheets/googleSheets.ts b/packages/nodes/src/integrations/google-sheets/googleSheets.ts index 488a2a0..1e69c0f 100644 --- a/packages/nodes/src/integrations/google-sheets/googleSheets.ts +++ b/packages/nodes/src/integrations/google-sheets/googleSheets.ts @@ -1,4 +1,4 @@ -import { defineOAuth2Credential } from '../types/credentials.js'; +import { defineOAuth2Credential } from '@jam-nodes/core'; import { z } from 'zod'; export const GoogleSheetsCredential = defineOAuth2Credential({ diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts index 25707be..819295c 100644 --- a/packages/nodes/src/integrations/index.ts +++ b/packages/nodes/src/integrations/index.ts @@ -102,6 +102,29 @@ export { SearchContactsOutputSchema, type SearchContactsInput, type SearchContactsOutput, + apolloCredential, + apolloEnrichPersonNode, + ApolloEnrichPersonInputSchema, + ApolloEnrichPersonOutputSchema, + ApolloEnrichedPersonSchema, + type ApolloEnrichPersonInput, + type ApolloEnrichPersonOutput, + type ApolloEnrichedPerson, + apolloEnrichCompanyNode, + ApolloEnrichCompanyInputSchema, + ApolloEnrichCompanyOutputSchema, + ApolloEnrichedOrganizationSchema, + type ApolloEnrichCompanyInput, + type ApolloEnrichCompanyOutput, + type ApolloEnrichedOrganization, + apolloGetEmailStatusNode, + ApolloGetEmailStatusInputSchema, + ApolloGetEmailStatusOutputSchema, + ApolloEmailStatusSchema, + mapApolloEmailStatus, + type ApolloGetEmailStatusInput, + type ApolloGetEmailStatusOutput, + type ApolloEmailStatus, } from './apollo/index.js' // Discord integrations