import * as z from "zod" import { defineAction } from "../../../automation/actions" import { integrationScope as scope } from "../../../automation/integrations" import { apolloAccountOptions, getApolloApi, type ApolloApi, type ApolloQueryValue, } from "../lib/api" import { APOLLO_ORGANIZATION_SCHEMA, APOLLO_PAGINATION_SCHEMA, APOLLO_PERSON_SCHEMA, APOLLO_PROVIDER_ORGANIZATION_SCHEMA, APOLLO_PROVIDER_PAGINATION_SCHEMA, APOLLO_PROVIDER_PERSON_SCHEMA, toApolloOrganization, toApolloPagination, toApolloPerson, } from "../lib/schemas" const ID_SCHEMA = z.string().trim().min(1) const DOMAIN_SCHEMA = z.string().trim().toLowerCase().min(1) const HTTPS_URL_SCHEMA = z .url() .refine( (value) => new URL(value).protocol === "https:", "Webhook URLs must use HTTPS.", ) const PAGE_INPUT_SCHEMA = z.object({ /** One-based page number. */ page: z.number().int().min(1).prefault(1), /** Number of results to return. Apollo allows at most 100. */ perPage: z.number().int().min(1).max(100).prefault(100), }) const JOB_POSTINGS_PAGE_INPUT_SCHEMA = z.object({ /** One-based page number. */ page: z.number().int().min(1).prefault(1), /** Number of job postings to return. */ perPage: z.number().int().min(1).max(10_000).prefault(100), }) const INTEGER_RANGE_SCHEMA = z .object({ /** Inclusive maximum. */ max: z.number().int().nonnegative().optional(), /** Inclusive minimum. */ min: z.number().int().nonnegative().optional(), }) .refine( ({ max, min }) => max === undefined || min === undefined || min <= max, { message: "min must be less than or equal to max.", path: ["max"], }, ) const DATE_RANGE_SCHEMA = z .object({ /** Inclusive latest date. */ max: z.iso.date().optional(), /** Inclusive earliest date. */ min: z.iso.date().optional(), }) .refine( ({ max, min }) => max === undefined || min === undefined || min <= max, { message: "min must be on or before max.", path: ["max"], }, ) const EMPLOYEE_RANGE_SCHEMA = z .object({ /** Inclusive maximum employee count. */ max: z.number().int().positive(), /** Inclusive minimum employee count. */ min: z.number().int().nonnegative(), }) .refine(({ max, min }) => min <= max, { message: "min must be less than or equal to max.", path: ["max"], }) const PERSON_MATCH_FIELDS = { /** Employer domain, without `www.`. */ domain: DOMAIN_SCHEMA.optional(), /** Work or personal email address. */ email: z.email().optional(), /** Person's first name. */ firstName: z.string().trim().min(1).optional(), /** MD5 or SHA-256 hash of the person's email address. */ hashedEmail: z .string() .trim() .regex(/^(?:[\da-f]{32}|[\da-f]{64})$/i) .optional(), /** Apollo person ID returned by search or enrichment. */ personId: ID_SCHEMA.optional(), /** Person's last name. */ lastName: z.string().trim().min(1).optional(), /** LinkedIn profile URL. */ linkedinUrl: z.url().optional(), /** Person's full name. */ name: z.string().trim().min(1).optional(), /** Current or previous employer name. */ organizationName: z.string().trim().min(1).optional(), } const PERSON_MATCH_SCHEMA = z .object(PERSON_MATCH_FIELDS) .refine(hasPersonIdentity, { message: "Provide a personId, email, hashedEmail, linkedinUrl, name, firstName, or lastName.", }) const ENRICHMENT_OPTIONS_FIELDS = { /** Include personal email addresses when Apollo permits disclosure. */ revealPersonalEmails: z.boolean().prefault(false), /** Reveal phone numbers asynchronously. Requires `webhookUrl`. */ revealPhoneNumber: z.boolean().prefault(false), /** Run configured email waterfall enrichment. Requires `webhookUrl`. */ runWaterfallEmail: z.boolean().prefault(false), /** Run configured phone waterfall enrichment. Requires `webhookUrl`. */ runWaterfallPhone: z.boolean().prefault(false), /** Public HTTPS endpoint that receives asynchronous enrichment results. */ webhookUrl: HTTPS_URL_SCHEMA.optional(), } const ENRICH_PERSON_INPUT_SCHEMA = z .object({ ...PERSON_MATCH_FIELDS, ...ENRICHMENT_OPTIONS_FIELDS, }) .refine(hasPersonIdentity, { message: "Provide a personId, email, hashedEmail, linkedinUrl, name, firstName, or lastName.", }) .superRefine(validateEnrichmentWebhook) const BULK_ENRICH_PEOPLE_INPUT_SCHEMA = z .object({ ...ENRICHMENT_OPTIONS_FIELDS, /** People to enrich. Apollo accepts at most 10 per request. */ people: PERSON_MATCH_SCHEMA.array().min(1).max(10), }) .superRefine(validateEnrichmentWebhook) const ORGANIZATION_MATCH_FIELDS = { /** Company domain, without `www.`. */ domain: DOMAIN_SCHEMA.optional(), /** Company LinkedIn page URL. */ linkedinUrl: z.url().optional(), /** Human-readable company name. */ name: z.string().trim().min(1).optional(), /** Full company website URL. */ website: z.url().optional(), } const ORGANIZATION_MATCH_SCHEMA = z .object(ORGANIZATION_MATCH_FIELDS) .refine( ({ domain, linkedinUrl, name, website }) => domain !== undefined || linkedinUrl !== undefined || name !== undefined || website !== undefined, "Provide a domain, linkedinUrl, name, or website.", ) const ORGANIZATION_REFERENCE_SCHEMA = z.union([ ID_SCHEMA, z.object({ id: ID_SCHEMA }), z.object({ domain: DOMAIN_SCHEMA }), z.object({ name: z.string().trim().min(1) }), ]) const PEOPLE_SEARCH_INPUT_SCHEMA = PAGE_INPUT_SCHEMA.extend({ /** Email availability states to include. */ emailStatuses: z .enum(["verified", "unverified", "likely to engage", "unavailable"]) .array() .optional(), /** Include people whose employers use all these Apollo technology UIDs. */ employerUsesAllTechnologies: ID_SCHEMA.array().optional(), /** Include people whose employers use any of these Apollo technology UIDs. */ employerUsesAnyTechnologies: ID_SCHEMA.array().optional(), /** Include similar job titles in addition to supplied titles. */ includeSimilarTitles: z.boolean().optional(), /** Free-text keywords to match. */ keywords: z.string().trim().min(1).optional(), /** Include people whose employers are hiring for these job titles. */ organizationHiringTitles: z.string().trim().min(1).array().optional(), /** Include people whose employers are hiring in these locations. */ organizationJobLocations: z.string().trim().min(1).array().optional(), /** Filter employers by active job count. */ organizationJobCount: INTEGER_RANGE_SCHEMA.optional(), /** Filter employers by active job posting date. */ organizationJobPostedAt: DATE_RANGE_SCHEMA.optional(), /** Apollo organization IDs to include. */ organizationIds: ID_SCHEMA.array().optional(), /** Employer headquarter locations. */ organizationLocations: z.string().trim().min(1).array().optional(), /** Employer domains, without `www.`. Apollo accepts at most 1,000. */ organizationDomains: DOMAIN_SCHEMA.array().max(1_000).optional(), /** Employer employee-count ranges. */ organizationEmployeeRanges: EMPLOYEE_RANGE_SCHEMA.array().optional(), /** Employer annual revenue range. */ organizationRevenue: INTEGER_RANGE_SCHEMA.optional(), /** Exclude people whose employers use any of these technology UIDs. */ organizationUsesNoneOfTechnologies: ID_SCHEMA.array().optional(), /** Locations where people live. */ personLocations: z.string().trim().min(1).array().optional(), /** Current job seniorities to include. */ seniorities: z .enum([ "owner", "founder", "c_suite", "partner", "vp", "head", "director", "manager", "senior", "entry", "intern", ]) .array() .optional(), /** Current job titles to include. */ titles: z.string().trim().min(1).array().optional(), }) const ORGANIZATION_SEARCH_INPUT_SCHEMA = PAGE_INPUT_SCHEMA.extend({ /** Company domains, without `www.`. Apollo accepts at most 1,000. */ domains: DOMAIN_SCHEMA.array().max(1_000).optional(), /** Company employee-count ranges. */ employeeRanges: EMPLOYEE_RANGE_SCHEMA.array().optional(), /** Latest funding amount range. */ latestFundingAmount: INTEGER_RANGE_SCHEMA.optional(), /** Latest funding date range. */ latestFundingDate: DATE_RANGE_SCHEMA.optional(), /** Company headquarter locations to include. */ locations: z.string().trim().min(1).array().optional(), /** Company headquarter locations to exclude. */ excludedLocations: z.string().trim().min(1).array().optional(), /** Company name to match. Partial matches are supported. */ name: z.string().trim().min(1).optional(), /** Apollo organization IDs to include. */ organizationIds: ID_SCHEMA.array().optional(), /** Active job count range. */ organizationJobCount: INTEGER_RANGE_SCHEMA.optional(), /** Active job posting date range. */ organizationJobPostedAt: DATE_RANGE_SCHEMA.optional(), /** Active job locations. */ organizationJobLocations: z.string().trim().min(1).array().optional(), /** Active job titles. */ organizationJobTitles: z.string().trim().min(1).array().optional(), /** Company keyword tags. */ organizationKeywords: z.string().trim().min(1).array().optional(), /** Annual revenue range. */ revenue: INTEGER_RANGE_SCHEMA.optional(), /** Total funding amount range. */ totalFundingAmount: INTEGER_RANGE_SCHEMA.optional(), /** Apollo technology UIDs used by the company. */ usesAnyTechnologies: ID_SCHEMA.array().optional(), }) const PROVIDER_PEOPLE_SEARCH_RESPONSE_SCHEMA = z.looseObject({ people: z .looseObject({ first_name: z.string().nullable().optional(), has_city: z.boolean().optional(), has_country: z.boolean().optional(), has_direct_phone: z.string().nullable().optional(), has_email: z.boolean().optional(), has_state: z.boolean().optional(), id: ID_SCHEMA, last_name_obfuscated: z.string().nullable().optional(), last_refreshed_at: z.string().nullable().optional(), organization: z .looseObject({ has_city: z.boolean().optional(), has_country: z.boolean().optional(), has_employee_count: z.boolean().optional(), has_industry: z.boolean().optional(), has_phone: z.boolean().optional(), has_revenue: z.boolean().optional(), has_state: z.boolean().optional(), has_zip_code: z.boolean().optional(), name: z.string().nullable().optional(), }) .nullable() .optional(), title: z.string().nullable().optional(), }) .array(), total_entries: z.number().int().nonnegative(), }) const PEOPLE_SEARCH_RESULT_SCHEMA = z.object({ /** People on this page. Search intentionally omits emails and phone numbers. */ people: z .object({ /** Whether Apollo has city data. */ hasCity: z.boolean().optional(), /** Whether Apollo has country data. */ hasCountry: z.boolean().optional(), /** Apollo's direct-phone availability indicator. */ hasDirectPhone: z.string().optional(), /** Whether Apollo has an email address. */ hasEmail: z.boolean().optional(), /** Whether Apollo has state data. */ hasState: z.boolean().optional(), /** Apollo person ID for enrichment and complete-person lookups. */ id: ID_SCHEMA, /** Person's first name. */ firstName: z.string().optional(), /** Privacy-obfuscated last name. */ lastNameObfuscated: z.string().optional(), /** Timestamp when Apollo last refreshed this record. */ lastRefreshedAt: z.string().optional(), /** Sparse availability information about the current employer. */ organization: z .object({ hasCity: z.boolean().optional(), hasCountry: z.boolean().optional(), hasEmployeeCount: z.boolean().optional(), hasIndustry: z.boolean().optional(), hasPhone: z.boolean().optional(), hasRevenue: z.boolean().optional(), hasState: z.boolean().optional(), hasZipCode: z.boolean().optional(), name: z.string().optional(), }) .optional(), /** Current job title. */ title: z.string().optional(), }) .array(), /** Page and result-count metadata. */ pagination: APOLLO_PAGINATION_SCHEMA, }) const PROVIDER_PERSON_RESPONSE_SCHEMA = z.looseObject({ person: APOLLO_PROVIDER_PERSON_SCHEMA.nullable(), request_id: z.union([z.string(), z.number()]).optional(), }) const ENRICH_PERSON_RESULT_SCHEMA = z.object({ /** Matched person, or null when Apollo found no match. */ person: APOLLO_PERSON_SCHEMA.nullable(), /** Request ID used to poll asynchronous phone or waterfall enrichment. */ requestId: z.union([z.string(), z.number()]).optional(), }) const PROVIDER_BULK_PEOPLE_RESPONSE_SCHEMA = z.looseObject({ credits_consumed: z.number().nonnegative().optional(), error_code: z.string().nullable().optional(), error_message: z.string().nullable().optional(), matches: APOLLO_PROVIDER_PERSON_SCHEMA.nullable().array(), missing_records: z.number().int().nonnegative().optional(), phone_enrichment: z.json().optional(), request_id: z.union([z.string(), z.number()]).optional(), status: z.string(), total_requested_enrichments: z.number().int().nonnegative(), unique_enriched_records: z.number().int().nonnegative().optional(), waterfall: z.json().optional(), }) const BULK_ENRICH_PEOPLE_RESULT_SCHEMA = z.object({ /** Credits reported as consumed by this request. */ creditsConsumed: z.number().optional(), /** Provider error code for a partially successful response. */ errorCode: z.string().optional(), /** Provider error message for a partially successful response. */ errorMessage: z.string().optional(), /** Matched people in request order; null entries represent missing people. */ matches: APOLLO_PERSON_SCHEMA.nullable().array(), /** Number of people Apollo could not match. */ missingRecords: z.number().int().nonnegative().optional(), /** Provider-native asynchronous phone-enrichment status. */ phoneEnrichment: z.json().optional(), /** Request ID used to poll asynchronous enrichment. */ requestId: z.union([z.string(), z.number()]).optional(), /** Overall enrichment status. */ status: z.string(), /** Number of submitted people, including duplicates. */ totalRequested: z.number().int().nonnegative(), /** Number of unique people Apollo enriched. */ uniqueEnrichedRecords: z.number().int().nonnegative().optional(), /** Provider-native waterfall-enrichment status. */ waterfall: z.json().optional(), }) const PROVIDER_ORGANIZATION_RESPONSE_SCHEMA = z.looseObject({ organization: APOLLO_PROVIDER_ORGANIZATION_SCHEMA.nullable(), }) const PROVIDER_ORGANIZATION_SEARCH_RESPONSE_SCHEMA = z.looseObject({ organizations: APOLLO_PROVIDER_ORGANIZATION_SCHEMA.array(), pagination: APOLLO_PROVIDER_PAGINATION_SCHEMA, partial_results_only: z.boolean().optional(), }) const ORGANIZATION_SEARCH_RESULT_SCHEMA = z.object({ /** Matching organizations on this page. */ organizations: APOLLO_ORGANIZATION_SCHEMA.array(), /** Page and result-count metadata. */ pagination: APOLLO_PAGINATION_SCHEMA, /** Whether Apollo returned only a partial result set. */ partialResultsOnly: z.boolean().optional(), }) const ENRICH_ORGANIZATION_RESULT_SCHEMA = z.object({ /** Matched organization, or null when Apollo found no match. */ organization: APOLLO_ORGANIZATION_SCHEMA.nullable(), }) const PROVIDER_BULK_ORGANIZATIONS_RESPONSE_SCHEMA = z.looseObject({ error_code: z.string().nullable().optional(), error_message: z.string().nullable().optional(), missing_records: z.number().int().nonnegative(), organizations: APOLLO_PROVIDER_ORGANIZATION_SCHEMA.array(), status: z.string(), total_requested_domains: z.number().int().nonnegative().nullable().optional(), total_requested_records: z.number().int().nonnegative().nullable().optional(), unique_domains: z.number().int().nonnegative().nullable().optional(), unique_enriched_records: z.number().int().nonnegative(), unique_records: z.number().int().nonnegative().nullable().optional(), }) const BULK_ENRICH_ORGANIZATIONS_RESULT_SCHEMA = z.object({ /** Provider error code for a partially successful response. */ errorCode: z.string().optional(), /** Provider error message for a partially successful response. */ errorMessage: z.string().optional(), /** Number of companies Apollo could not match. */ missingRecords: z.number().int().nonnegative(), /** Enriched organizations. */ organizations: APOLLO_ORGANIZATION_SCHEMA.array(), /** Overall enrichment status. */ status: z.string(), /** Number of submitted records, including duplicates. */ totalRequested: z.number().int().nonnegative().optional(), /** Number of unique organizations Apollo enriched. */ uniqueEnrichedRecords: z.number().int().nonnegative(), /** Number of unique submitted organizations after deduplication. */ uniqueRecords: z.number().int().nonnegative().optional(), }) const PROVIDER_JOB_POSTING_SCHEMA = z.looseObject({ city: z.string().nullable().optional(), country: z.string().nullable().optional(), id: ID_SCHEMA, last_seen_at: z.string().nullable().optional(), posted_at: z.string().nullable().optional(), state: z.string().nullable().optional(), title: z.string(), url: z.url(), }) const PROVIDER_JOB_POSTINGS_RESPONSE_SCHEMA = z.looseObject({ organization_job_postings: PROVIDER_JOB_POSTING_SCHEMA.array(), }) const JOB_POSTINGS_RESULT_SCHEMA = z.object({ /** Whether another page may exist. Apollo does not return a total count. */ hasMore: z.boolean(), /** Job postings on this page. */ jobPostings: z .object({ city: z.string().optional(), country: z.string().optional(), id: ID_SCHEMA, lastSeenAt: z.string().optional(), postedAt: z.string().optional(), state: z.string().optional(), title: z.string(), url: z.url(), }) .array(), /** One-based page number. */ page: z.number().int().positive(), /** Requested page size. */ perPage: z.number().int().positive(), }) const PROVIDER_WEBHOOK_RESULT_SCHEMA = z.looseObject({ failure_reason: z.string().nullable(), last_dispatched_at: z.string().nullable(), request_id: z.union([z.string(), z.number()]), request_type: z.enum(["email", "phone", "waterfall"]), webhook_result: z.json().nullable(), webhook_status: z.enum(["in_progress", "success", "failed"]), }) const WEBHOOK_RESULT_SCHEMA = z.object({ /** Failure message from Apollo's last webhook delivery attempt. */ failureReason: z.string().optional(), /** Timestamp of Apollo's latest webhook delivery attempt. */ lastDispatchedAt: z.string().optional(), /** Enrichment request ID. */ requestId: z.union([z.string(), z.number()]), /** Kind of asynchronous enrichment request. */ requestType: z.enum(["email", "phone", "waterfall"]), /** Provider-native enrichment result, when ready. */ result: z.json().nullable(), /** Current asynchronous request status. */ status: z.enum(["in_progress", "success", "failed"]), }) /** Searches Apollo's prospect database without consuming enrichment credits. */ export const searchApolloPeople = defineAction("Search Apollo people") .describe( "Searches one explicit page of Apollo prospects. Results omit email addresses and phone numbers; use enrichment when those fields are needed.", ) .account("apollo", apolloAccountOptions("mixed_people_api_search")) .input(PEOPLE_SEARCH_INPUT_SCHEMA) .output(PEOPLE_SEARCH_RESULT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const response = await getApolloApi(account).request( "/mixed_people/api_search", { method: "POST", query: toPeopleSearchQuery(input), responseSchema: PROVIDER_PEOPLE_SEARCH_RESPONSE_SCHEMA, }, ) return { pagination: { page: input.page, perPage: input.perPage, totalEntries: response.total_entries, totalPages: Math.ceil(response.total_entries / input.perPage), }, people: response.people.map((person) => ({ firstName: person.first_name ?? undefined, hasCity: person.has_city, hasCountry: person.has_country, hasDirectPhone: person.has_direct_phone ?? undefined, hasEmail: person.has_email, hasState: person.has_state, id: person.id, lastNameObfuscated: person.last_name_obfuscated ?? undefined, lastRefreshedAt: person.last_refreshed_at ?? undefined, organization: person.organization ? { hasCity: person.organization.has_city, hasCountry: person.organization.has_country, hasEmployeeCount: person.organization.has_employee_count, hasIndustry: person.organization.has_industry, hasPhone: person.organization.has_phone, hasRevenue: person.organization.has_revenue, hasState: person.organization.has_state, hasZipCode: person.organization.has_zip_code, name: person.organization.name ?? undefined, } : undefined, title: person.title ?? undefined, })), } }) /** Enriches one person using readable identity data or an Apollo person ID. */ export const enrichApolloPerson = defineAction("Enrich Apollo person") .describe( "Enriches one person for 1–9 base credits. Phone and waterfall enrichment complete asynchronously, require a public HTTPS webhook URL, and waterfall vendors may charge even without a match.", ) .account("apollo", apolloAccountOptions("people_match")) .input(ENRICH_PERSON_INPUT_SCHEMA) .output(ENRICH_PERSON_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const response = await getApolloApi(account).request("/people/match", { method: "POST", query: toPersonEnrichmentQuery(input), responseSchema: PROVIDER_PERSON_RESPONSE_SCHEMA, }) return { person: response.person ? toApolloPerson(response.person) : null, requestId: response.request_id, } }) /** Enriches up to 10 people in one Apollo request. */ export const bulkEnrichApolloPeople = defineAction("Bulk enrich Apollo people") .describe( "Enriches up to 10 people for up to 90 base credits. Phone and waterfall enrichment complete asynchronously, require a public HTTPS webhook URL, and waterfall vendors may charge even without a match.", ) .account("apollo", apolloAccountOptions("people_bulk_match")) .input(BULK_ENRICH_PEOPLE_INPUT_SCHEMA) .output(BULK_ENRICH_PEOPLE_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const response = await getApolloApi(account).request("/people/bulk_match", { body: { details: input.people.map(toPersonMatchBody) }, method: "POST", query: toEnrichmentOptionsQuery(input), responseSchema: PROVIDER_BULK_PEOPLE_RESPONSE_SCHEMA, }) return { creditsConsumed: response.credits_consumed, errorCode: response.error_code ?? undefined, errorMessage: response.error_message ?? undefined, matches: response.matches.map((person) => person ? toApolloPerson(person) : null, ), missingRecords: response.missing_records, phoneEnrichment: response.phone_enrichment, requestId: response.request_id, status: response.status, totalRequested: response.total_requested_enrichments, uniqueEnrichedRecords: response.unique_enriched_records, waterfall: response.waterfall, } }) /** Gets Apollo's complete stored record for a person ID. */ export const getApolloPerson = defineAction("Get Apollo person") .describe( "Gets complete Apollo data for a person ID returned by search or enrichment. This endpoint consumes one credit.", ) .account("apollo", apolloAccountOptions("person_read")) .input(z.object({ personId: ID_SCHEMA })) .output(APOLLO_PERSON_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const response = await getApolloApi(account).request( `/people/${encodeURIComponent(input.personId)}`, { method: "GET", responseSchema: PROVIDER_PERSON_RESPONSE_SCHEMA }, ) if (!response.person) throw new Error("Apollo returned no person record.") return toApolloPerson(response.person) }) /** Searches one explicit page of Apollo organizations. */ export const searchApolloOrganizations = defineAction( "Search Apollo organizations", ) .describe( "Searches one explicit page of Apollo organizations. Each requested page consumes one credit.", ) .account("apollo", apolloAccountOptions("mixed_companies_search")) .input(ORGANIZATION_SEARCH_INPUT_SCHEMA) .output(ORGANIZATION_SEARCH_RESULT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const response = await getApolloApi(account).request( "/mixed_companies/search", { method: "POST", query: toOrganizationSearchQuery(input), responseSchema: PROVIDER_ORGANIZATION_SEARCH_RESPONSE_SCHEMA, }, ) return { organizations: response.organizations.map(toApolloOrganization), pagination: toApolloPagination(response.pagination), partialResultsOnly: response.partial_results_only, } }) /** Enriches one organization using readable company identity data. */ export const enrichApolloOrganization = defineAction( "Enrich Apollo organization", ) .describe( "Enriches one company by domain, LinkedIn URL, name, or website. A matched company consumes one credit.", ) .account("apollo", apolloAccountOptions("organizations_enrich")) .input(ORGANIZATION_MATCH_SCHEMA) .output(ENRICH_ORGANIZATION_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const response = await getApolloApi(account).request( "/organizations/enrich", { method: "GET", query: toOrganizationMatchQuery(input), responseSchema: PROVIDER_ORGANIZATION_RESPONSE_SCHEMA, }, ) return { organization: response.organization ? toApolloOrganization(response.organization) : null, } }) /** Enriches up to 10 organizations in one Apollo request. */ export const bulkEnrichApolloOrganizations = defineAction( "Bulk enrich Apollo organizations", ) .describe( "Enriches up to 10 companies by domain, LinkedIn URL, name, or website. Each matched company consumes one credit.", ) .account("apollo", apolloAccountOptions("organizations_bulk_enrich")) .input( z.object({ /** Companies to enrich. Apollo accepts at most 10 per request. */ organizations: ORGANIZATION_MATCH_SCHEMA.array().min(1).max(10), }), ) .output(BULK_ENRICH_ORGANIZATIONS_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const response = await getApolloApi(account).request( "/organizations/bulk_enrich", { body: { details: input.organizations.map(toOrganizationMatchBody) }, method: "POST", responseSchema: PROVIDER_BULK_ORGANIZATIONS_RESPONSE_SCHEMA, }, ) return { errorCode: response.error_code ?? undefined, errorMessage: response.error_message ?? undefined, missingRecords: response.missing_records, organizations: response.organizations.map(toApolloOrganization), status: response.status, totalRequested: response.total_requested_records ?? response.total_requested_domains ?? undefined, uniqueEnrichedRecords: response.unique_enriched_records, uniqueRecords: response.unique_records ?? response.unique_domains ?? undefined, } }) /** Gets Apollo's complete stored record for an organization. */ export const getApolloOrganization = defineAction("Get Apollo organization") .describe( "Gets complete organization data. Name and domain references require one additional credit-consuming search page before the one-credit lookup.", ) .account( "apollo", apolloAccountOptions( scope.and("organization_read", "mixed_companies_search"), ), ) .input(z.object({ organization: ORGANIZATION_REFERENCE_SCHEMA })) .output(APOLLO_ORGANIZATION_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) const response = await api.request( `/organizations/${encodeURIComponent( await resolveOrganizationId(api, input.organization), )}`, { method: "GET", responseSchema: PROVIDER_ORGANIZATION_RESPONSE_SCHEMA }, ) if (!response.organization) throw new Error("Apollo returned no organization record.") return toApolloOrganization(response.organization) }) /** Lists one explicit page of job postings for an Apollo organization. */ export const listApolloOrganizationJobPostings = defineAction( "List Apollo organization job postings", ) .describe( "Lists one explicit one-credit page of company job postings. Name and domain references require an additional credit-consuming organization search.", ) .account( "apollo", apolloAccountOptions( scope.and("organizations_job_posting", "mixed_companies_search"), ), ) .input( JOB_POSTINGS_PAGE_INPUT_SCHEMA.extend({ /** Apollo organization ID, exact domain, or exact name. */ organization: ORGANIZATION_REFERENCE_SCHEMA, }), ) .output(JOB_POSTINGS_RESULT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) const response = await api.request( `/organizations/${encodeURIComponent( await resolveOrganizationId(api, input.organization), )}/job_postings`, { method: "GET", query: { page: input.page, per_page: input.perPage }, responseSchema: PROVIDER_JOB_POSTINGS_RESPONSE_SCHEMA, }, ) return { hasMore: response.organization_job_postings.length === input.perPage, jobPostings: response.organization_job_postings.map((job) => ({ city: job.city ?? undefined, country: job.country ?? undefined, id: job.id, lastSeenAt: job.last_seen_at ?? undefined, postedAt: job.posted_at ?? undefined, state: job.state ?? undefined, title: job.title, url: job.url, })), page: input.page, perPage: input.perPage, } }) /** Polls an asynchronous Apollo phone or waterfall enrichment result. */ export const pollApolloWebhookResult = defineAction( "Poll Apollo webhook result", ) .describe( "Gets the current status and provider-native result for an asynchronous phone or waterfall enrichment request.", ) .account("apollo", apolloAccountOptions("webhook_result_read")) .input( z.object({ /** Request ID returned by an enrichment action. */ requestId: z.union([ID_SCHEMA, z.number().int()]), }), ) .output(WEBHOOK_RESULT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const response = await getApolloApi(account).request( `/webhook_result/${encodeURIComponent(String(input.requestId))}`, { method: "GET", responseSchema: PROVIDER_WEBHOOK_RESULT_SCHEMA }, ) return { failureReason: response.failure_reason ?? undefined, lastDispatchedAt: response.last_dispatched_at ?? undefined, requestId: response.request_id, requestType: response.request_type, result: response.webhook_result, status: response.webhook_status, } }) /** Identity fields accepted by Apollo's person matching endpoints. */ type PersonIdentity = { email?: string firstName?: string hashedEmail?: string lastName?: string linkedinUrl?: string name?: string personId?: string } /** Options shared by single and bulk person enrichment. */ type EnrichmentOptions = { revealPersonalEmails: boolean revealPhoneNumber: boolean runWaterfallEmail: boolean runWaterfallPhone: boolean webhookUrl?: string } type OrganizationMatch = z.output /** * Checks that enough person identity data was supplied to attempt a match. * * @param input - Candidate identity fields. */ function hasPersonIdentity(input: PersonIdentity) { return ( input.personId !== undefined || input.email !== undefined || input.hashedEmail !== undefined || input.linkedinUrl !== undefined || input.name !== undefined || input.firstName !== undefined || input.lastName !== undefined ) } /** * Applies Apollo's webhook requirement to asynchronous enrichment options. * * @param input - Enrichment options that may start asynchronous work. * @param context - Zod refinement context. */ function validateEnrichmentWebhook( input: EnrichmentOptions, context: z.RefinementCtx, ) { const runsAsynchronously = input.revealPhoneNumber || input.runWaterfallEmail || input.runWaterfallPhone if (runsAsynchronously && input.webhookUrl === undefined) { context.addIssue({ code: "custom", message: "webhookUrl is required for phone reveal or waterfall enrichment.", path: ["webhookUrl"], }) } if (!runsAsynchronously && input.webhookUrl !== undefined) { context.addIssue({ code: "custom", message: "webhookUrl is only used for phone reveal or waterfall enrichment.", path: ["webhookUrl"], }) } } /** * Serializes one person match into Apollo's provider-native shape. * * @param person - Public person matching fields. */ function toPersonMatchBody(person: z.output) { return { ...(person.domain && { domain: person.domain }), ...(person.email && { email: person.email }), ...(person.firstName && { first_name: person.firstName }), ...(person.hashedEmail && { hashed_email: person.hashedEmail }), ...(person.personId && { id: person.personId }), ...(person.lastName && { last_name: person.lastName }), ...(person.linkedinUrl && { linkedin_url: person.linkedinUrl }), ...(person.name && { name: person.name }), ...(person.organizationName && { organization_name: person.organizationName, }), } } /** * Serializes one organization match into Apollo's provider-native shape. * * @param organization - Public organization matching fields. */ function toOrganizationMatchBody(organization: OrganizationMatch) { return { ...(organization.domain && { domain: organization.domain }), ...(organization.linkedinUrl && { linkedin_url: organization.linkedinUrl, }), ...(organization.name && { name: organization.name }), ...(organization.website && { website: organization.website }), } } /** * Serializes one organization match as Apollo query parameters. * * @param organization - Public organization matching fields. */ function toOrganizationMatchQuery( organization: OrganizationMatch, ): Record { return { domain: organization.domain, linkedin_url: organization.linkedinUrl, name: organization.name, website: organization.website, } } /** * Serializes one person enrichment request as Apollo query parameters. * * @param input - Public person enrichment input. */ function toPersonEnrichmentQuery( input: z.output, ): Record { return { ...toPersonMatchBody(input), ...toEnrichmentOptionsQuery(input), } } /** * Serializes Apollo's common enrichment options. * * @param input - Public enrichment options. */ function toEnrichmentOptionsQuery(input: EnrichmentOptions) { return { reveal_personal_emails: input.revealPersonalEmails, reveal_phone_number: input.revealPhoneNumber, run_waterfall_email: input.runWaterfallEmail, run_waterfall_phone: input.runWaterfallPhone, webhook_url: input.webhookUrl, } } /** * Serializes a page-bounded people search. * * @param input - Public people-search filters and pagination. */ function toPeopleSearchQuery( input: z.output, ): Record { return { "contact_email_status[]": input.emailStatuses, "currently_not_using_any_of_technology_uids[]": input.organizationUsesNoneOfTechnologies, "currently_using_all_of_technology_uids[]": input.employerUsesAllTechnologies, "currently_using_any_of_technology_uids[]": input.employerUsesAnyTechnologies, include_similar_titles: input.includeSimilarTitles, "organization_ids[]": input.organizationIds, "organization_job_locations[]": input.organizationJobLocations, "organization_locations[]": input.organizationLocations, "organization_num_employees_ranges[]": input.organizationEmployeeRanges?.map(({ max, min }) => `${min},${max}`), "organization_num_jobs_range[max]": input.organizationJobCount?.max, "organization_num_jobs_range[min]": input.organizationJobCount?.min, "organization_job_posted_at_range[max]": input.organizationJobPostedAt?.max, "organization_job_posted_at_range[min]": input.organizationJobPostedAt?.min, page: input.page, per_page: input.perPage, "person_locations[]": input.personLocations, "person_seniorities[]": input.seniorities, "person_titles[]": input.titles, q_keywords: input.keywords, "q_organization_domains_list[]": input.organizationDomains, "q_organization_job_titles[]": input.organizationHiringTitles, "revenue_range[max]": input.organizationRevenue?.max, "revenue_range[min]": input.organizationRevenue?.min, } } /** * Serializes a page-bounded organization search. * * @param input - Public organization-search filters and pagination. */ function toOrganizationSearchQuery( input: z.output, ): Record { return { "currently_using_any_of_technology_uids[]": input.usesAnyTechnologies, "latest_funding_amount_range[max]": input.latestFundingAmount?.max, "latest_funding_amount_range[min]": input.latestFundingAmount?.min, "latest_funding_date_range[max]": input.latestFundingDate?.max, "latest_funding_date_range[min]": input.latestFundingDate?.min, "organization_ids[]": input.organizationIds, "organization_job_locations[]": input.organizationJobLocations, "organization_locations[]": input.locations, "organization_not_locations[]": input.excludedLocations, "organization_num_employees_ranges[]": input.employeeRanges?.map( ({ max, min }) => `${min},${max}`, ), "organization_num_jobs_range[max]": input.organizationJobCount?.max, "organization_num_jobs_range[min]": input.organizationJobCount?.min, "organization_job_posted_at_range[max]": input.organizationJobPostedAt?.max, "organization_job_posted_at_range[min]": input.organizationJobPostedAt?.min, page: input.page, per_page: input.perPage, "q_organization_domains_list[]": input.domains, "q_organization_job_titles[]": input.organizationJobTitles, "q_organization_keyword_tags[]": input.organizationKeywords, q_organization_name: input.name, "revenue_range[max]": input.revenue?.max, "revenue_range[min]": input.revenue?.min, "total_funding_range[max]": input.totalFundingAmount?.max, "total_funding_range[min]": input.totalFundingAmount?.min, } } /** * Resolves an exact human-readable organization reference to an Apollo ID. * * @param api - Authenticated Apollo API helper. * @param reference - Organization ID, exact domain, or exact name. */ async function resolveOrganizationId( api: ApolloApi, reference: z.output, ) { if (typeof reference === "string") return reference if ("id" in reference) return reference.id const matches = ( await api.request("/mixed_companies/search", { method: "POST", query: "domain" in reference ? { "q_organization_domains_list[]": [reference.domain], page: 1, per_page: 100, } : { page: 1, per_page: 100, q_organization_name: reference.name }, responseSchema: PROVIDER_ORGANIZATION_SEARCH_RESPONSE_SCHEMA, }) ).organizations.filter((organization) => "domain" in reference ? organization.primary_domain?.toLowerCase() === reference.domain : organization.name.localeCompare(reference.name, undefined, { sensitivity: "accent", }) === 0, ) if (matches.length === 0) { throw new Error( `No Apollo organization exactly matched ${"domain" in reference ? `domain ${reference.domain}` : `name ${reference.name}`}.`, ) } if (matches.length > 1) { throw new Error( `Multiple Apollo organizations exactly matched ${"domain" in reference ? `domain ${reference.domain}` : `name ${reference.name}`}; use an organization ID instead.`, ) } const organizationId = matches[0]!.id if (!organizationId) { throw new Error("The matching Apollo organization has no stable ID.") } return organizationId }