import * as z from "zod" import { defineAction } from "../../../automation/actions" import { integrationScope as scope } from "../../../automation/integrations" import { apolloAccountOptions, getApolloApi } from "../lib/api" import { resolveApolloAccount, resolveApolloContact, resolveApolloContactStage, resolveApolloList, resolveApolloReferences, resolveApolloUser, } from "../lib/references" import { APOLLO_CONTACT_SCHEMA, APOLLO_PAGINATION_SCHEMA, APOLLO_PROVIDER_CONTACT_SCHEMA, APOLLO_PROVIDER_STAGE_SCHEMA, APOLLO_REFERENCE_SCHEMA, APOLLO_STAGE_SCHEMA, toApolloContact, toApolloPagination, toApolloStage, } from "../lib/schemas" const CUSTOM_FIELDS_SCHEMA = z.record(z.string(), z.string()) const CONTACT_RESPONSE_SCHEMA = z.looseObject({ contact: APOLLO_PROVIDER_CONTACT_SCHEMA, }) const CONTACTS_RESPONSE_SCHEMA = z.looseObject({ contacts: APOLLO_PROVIDER_CONTACT_SCHEMA.array().prefault([]), }) const CONTACT_SEARCH_RESPONSE_SCHEMA = CONTACTS_RESPONSE_SCHEMA.extend({ pagination: z.looseObject({ page: z.number().int(), per_page: z.number().int(), total_entries: z.number().int(), total_pages: z.number().int(), }), }) const CONTACT_PAGE_SCHEMA = z.object({ /** Contacts returned on this page. */ contacts: APOLLO_CONTACT_SCHEMA.array(), /** Pagination metadata for requesting another page. */ pagination: APOLLO_PAGINATION_SCHEMA, }) const CONTACT_STAGES_RESPONSE_SCHEMA = z.looseObject({ contact_stages: APOLLO_PROVIDER_STAGE_SCHEMA.array().prefault([]), }) const CONTACT_MUTATION_SCHEMA = z.object({ /** Associated Apollo account ID, exact name/domain, or explicit ID reference. */ associatedAccount: APOLLO_REFERENCE_SCHEMA.optional(), /** Work phone number. */ corporatePhone: z.string().min(1).optional(), /** Custom field values keyed by Apollo field ID. */ customFields: CUSTOM_FIELDS_SCHEMA.optional(), /** Primary direct phone number. */ directPhone: z.string().min(1).optional(), /** Primary email address. */ email: z.email().optional(), /** First name. */ firstName: z.string().min(1).optional(), /** Home phone number. */ homePhone: z.string().min(1).optional(), /** Exact Apollo list names to assign. */ listNames: z.string().trim().min(1).array().optional(), /** Last name. */ lastName: z.string().min(1).optional(), /** Mobile phone number. */ mobilePhone: z.string().min(1).optional(), /** Employer name. */ organizationName: z.string().min(1).optional(), /** Alternate phone number. */ otherPhone: z.string().min(1).optional(), /** Human-readable location or address. */ presentAddress: z.string().min(1).optional(), /** Contact stage ID, exact name, or explicit ID reference. */ stage: APOLLO_REFERENCE_SCHEMA.optional(), /** Current job title. */ title: z.string().min(1).optional(), /** Employer website URL. */ websiteUrl: z.url().optional(), }) const CREATE_CONTACT_INPUT_SCHEMA = CONTACT_MUTATION_SCHEMA.extend({ /** Upsert a matching contact and overwrite the fields supplied here. */ runDedupe: z.boolean().prefault(false), }).refine(hasContactMutation, "Provide at least one contact field.") const UPDATE_CONTACT_INPUT_SCHEMA = CONTACT_MUTATION_SCHEMA.extend({ /** Contact ID, exact email/name, or explicit ID reference. */ contact: APOLLO_REFERENCE_SCHEMA, }).refine(hasContactMutation, "Provide at least one contact field to update.") const BULK_CONTACT_FIELDS_SCHEMA = z.object({ /** Apollo account ID, exact account name/domain, or explicit ID reference. */ account: APOLLO_REFERENCE_SCHEMA.optional(), /** Custom field values keyed by Apollo field ID. */ customFields: CUSTOM_FIELDS_SCHEMA.optional(), /** Primary email address. */ email: z.email().optional(), /** First name. */ firstName: z.string().min(1).optional(), /** Last name. */ lastName: z.string().min(1).optional(), /** LinkedIn profile URL. */ linkedinUrl: z.url().optional(), /** Employer name. */ organizationName: z.string().min(1).optional(), /** Human-readable location or address. */ presentAddress: z.string().min(1).optional(), /** Current job title. */ title: z.string().min(1).optional(), }) const BULK_CREATE_CONTACT_SCHEMA = BULK_CONTACT_FIELDS_SCHEMA.extend({ /** Apollo contact stage ID, exact name, or explicit ID reference. */ stage: APOLLO_REFERENCE_SCHEMA.optional(), }) const BULK_UPDATE_CONTACT_SCHEMA = BULK_CONTACT_FIELDS_SCHEMA.extend({ /** Contact ID, exact email/name, or explicit ID reference. */ contact: APOLLO_REFERENCE_SCHEMA, }).refine( hasBulkContactMutation, "Provide at least one contact field to update.", ) const BULK_CREATE_RESPONSE_SCHEMA = z.looseObject({ created_contacts: APOLLO_PROVIDER_CONTACT_SCHEMA.array().prefault([]), existing_contacts: APOLLO_PROVIDER_CONTACT_SCHEMA.array().prefault([]), }) const BULK_CREATE_OUTPUT_SCHEMA = z.object({ /** Newly created contacts. */ createdContacts: APOLLO_CONTACT_SCHEMA.array(), /** Existing contacts matched by deduplication. */ existingContacts: APOLLO_CONTACT_SCHEMA.array(), }) const SORT_FIELD_SCHEMA = z.enum([ "contact_created_at", "contact_email_last_clicked_at", "contact_email_last_opened_at", "contact_last_activity_date", "contact_updated_at", ]) /** Searches contacts saved in the connected Apollo workspace. */ export const searchApolloContacts = defineAction("Search Apollo contacts") .describe( "Searches saved Apollo contacts with filters and explicit pagination.", ) .account( "apollo", apolloAccountOptions( scope.and("contacts_search", "contact_stages_list", "tags_list"), ), ) .input( z.object({ /** Apollo list IDs, exact names, or explicit ID references. */ lists: APOLLO_REFERENCE_SCHEMA.array().optional(), /** One-indexed result page. */ page: z.number().int().min(1).prefault(1), /** Maximum contacts returned per page. */ perPage: z.number().int().min(1).max(100).prefault(100), /** Name, title, employer, or email keywords. */ query: z.string().trim().min(1).optional(), /** Sort oldest-to-newest instead of newest-to-oldest. */ sortAscending: z.boolean().prefault(false), /** Contact field used for sorting. */ sortBy: SORT_FIELD_SCHEMA.optional(), /** Contact stage IDs, exact names, or explicit ID references. */ stages: APOLLO_REFERENCE_SCHEMA.array().optional(), }), ) .output(CONTACT_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) // Resolves stage filters before the contact-search request. const stageIds = input.stages ? await resolveApolloReferences(input.stages, (reference) => resolveApolloContactStage(api, reference), ) : undefined // Resolves list filters before the contact-search request. const listIds = input.lists ? await resolveApolloReferences(input.lists, (reference) => resolveApolloList(api, reference, "contacts"), ) : undefined const result = await api.request("contacts/search", { body: { contact_label_ids: listIds, contact_stage_ids: stageIds, page: input.page, per_page: input.perPage, q_keywords: input.query, sort_ascending: input.sortAscending, sort_by_field: input.sortBy, }, responseSchema: CONTACT_SEARCH_RESPONSE_SCHEMA, }) return { contacts: result.contacts.map(toApolloContact), pagination: toApolloPagination(result.pagination), } }) /** Gets one saved Apollo contact by ID, exact email, or exact name. */ export const getApolloContact = defineAction("Get Apollo contact") .describe("Gets one saved Apollo contact using a semantic reference.") .account( "apollo", apolloAccountOptions(scope.and("contact_read", "contacts_search")), ) .input(z.object({ contact: APOLLO_REFERENCE_SCHEMA })) .output(APOLLO_CONTACT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) // Names the canonical ID used to build the provider request path. const contactId = await resolveApolloContact(api, input.contact) // Keeps the response envelope readable while normalizing its contact. const result = await api.request( `contacts/${encodeURIComponent(contactId)}`, { responseSchema: CONTACT_RESPONSE_SCHEMA, }, ) return toApolloContact(result.contact) }) /** Creates or explicitly upserts a saved Apollo contact. */ export const createApolloContact = defineAction("Create Apollo contact") .describe( "Creates a contact. Optional deduplication is an upsert that can overwrite supplied fields on a matching existing contact.", ) .account( "apollo", apolloAccountOptions( scope.and( "contact_write", "account_read", "accounts_search", "contact_stages_list", ), ), ) .input(CREATE_CONTACT_INPUT_SCHEMA) .output(APOLLO_CONTACT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) // Keeps the response envelope readable while normalizing its contact. const result = await api.request("contacts", { body: { ...(await toContactMutationBody(api, input)), run_dedupe: input.runDedupe, }, responseSchema: CONTACT_RESPONSE_SCHEMA, }) return toApolloContact(result.contact) }) /** Updates selected fields on a saved Apollo contact. */ export const updateApolloContact = defineAction("Update Apollo contact") .describe("Updates selected contact fields using a semantic reference.") .account( "apollo", apolloAccountOptions( scope.and( "contact_update", "contact_read", "contacts_search", "account_read", "accounts_search", "contact_stages_list", ), ), ) .input(UPDATE_CONTACT_INPUT_SCHEMA) .output(APOLLO_CONTACT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) // Names the canonical ID used to build the provider request path. const contactId = await resolveApolloContact(api, input.contact) // Keeps the response envelope readable while normalizing its contact. const result = await api.request( `contacts/${encodeURIComponent(contactId)}`, { body: await toContactMutationBody(api, input), method: "PATCH", responseSchema: CONTACT_RESPONSE_SCHEMA, }, ) return toApolloContact(result.contact) }) /** Creates up to 100 Apollo contacts in one request. */ export const bulkCreateApolloContacts = defineAction( "Bulk create Apollo contacts", ) .describe( "Creates up to 100 contacts and reports deduplication matches separately.", ) .account( "apollo", apolloAccountOptions( scope.and( "contacts_bulk_create", "account_read", "accounts_search", "contact_stages_list", "users_list", ), ), ) .input( z.object({ /** Apollo list names added to every new contact. */ listNames: z.string().trim().min(1).array().optional(), /** Owner ID, exact email/name, or explicit ID reference. */ owner: APOLLO_REFERENCE_SCHEMA.optional(), /** Contacts to create. */ contacts: BULK_CREATE_CONTACT_SCHEMA.array().min(1).max(100), /** Return existing matches instead of creating duplicates. */ runDedupe: z.boolean().prefault(true), }), ) .output(BULK_CREATE_OUTPUT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) // Resolves the shared owner once before constructing the bulk request. const ownerId = input.owner ? await resolveApolloUser(api, input.owner) : undefined // Resolves per-contact references before submitting the bounded batch. const contacts = await Promise.all( input.contacts.map(async (contact) => ({ ...toBulkContactFields(contact), account_id: contact.account ? await resolveApolloAccount(api, contact.account) : undefined, contact_stage_id: contact.stage ? await resolveApolloContactStage(api, contact.stage) : undefined, })), ) const result = await api.request("contacts/bulk_create", { body: { append_label_names: input.listNames, contacts, owner_id: ownerId, run_dedupe: input.runDedupe, }, responseSchema: BULK_CREATE_RESPONSE_SCHEMA, }) return { createdContacts: result.created_contacts.map(toApolloContact), existingContacts: result.existing_contacts.map(toApolloContact), } }) /** Updates up to 100 Apollo contacts with per-contact changes. */ export const bulkUpdateApolloContacts = defineAction( "Bulk update Apollo contacts", ) .describe( "Updates up to 100 contacts synchronously with individual field changes.", ) .account( "apollo", apolloAccountOptions( scope.and( "contacts_bulk_update", "contact_read", "contacts_search", "account_read", "accounts_search", ), ), ) .input( z.object({ updates: BULK_UPDATE_CONTACT_SCHEMA.array().min(1).max(100) }), ) .output(APOLLO_CONTACT_SCHEMA.array()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) // Resolves every semantic reference before submitting the bounded batch. const contactAttributes = await Promise.all( input.updates.map(async (update) => ({ ...toBulkContactFields(update), account_id: update.account ? await resolveApolloAccount(api, update.account) : undefined, id: await resolveApolloContact(api, update.contact), })), ) // Keeps the response envelope readable while normalizing its contacts. const result = await api.request("contacts/bulk_update", { body: { async: false, contact_attributes: contactAttributes }, responseSchema: CONTACTS_RESPONSE_SCHEMA, }) return result.contacts.map(toApolloContact) }) /** Assigns one stage to one or more saved Apollo contacts. */ export const setApolloContactStage = defineAction("Set Apollo contact stage") .describe("Assigns a stage by ID or exact name to one or more contacts.") .account( "apollo", apolloAccountOptions( scope.and( "contact_stages_update", "contact_read", "contacts_search", "contact_stages_list", ), ), ) .input( z.object({ /** Contact IDs, exact emails/names, or explicit ID references. */ contacts: APOLLO_REFERENCE_SCHEMA.array().min(1), /** Stage ID, exact name, or explicit ID reference. */ stage: APOLLO_REFERENCE_SCHEMA, }), ) .output(APOLLO_CONTACT_SCHEMA.array()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) const [contactIds, stageId] = await Promise.all([ resolveApolloReferences(input.contacts, (reference) => resolveApolloContact(api, reference), ), resolveApolloContactStage(api, input.stage), ]) // Keeps the response envelope readable while normalizing its contacts. const result = await api.request("contacts/update_stages", { method: "POST", query: { "contact_ids[]": contactIds, contact_stage_id: stageId }, responseSchema: CONTACTS_RESPONSE_SCHEMA, }) return result.contacts.map(toApolloContact) }) /** Assigns one owner to one or more saved Apollo contacts. */ export const setApolloContactOwner = defineAction("Set Apollo contact owner") .describe("Assigns an owner by ID, exact email, or exact name.") .account( "apollo", apolloAccountOptions( scope.and( "contact_owners_update", "contact_read", "contacts_search", "users_list", ), ), ) .input( z.object({ /** Contact IDs, exact emails/names, or explicit ID references. */ contacts: APOLLO_REFERENCE_SCHEMA.array().min(1), /** Owner ID, exact email/name, or explicit ID reference. */ owner: APOLLO_REFERENCE_SCHEMA, }), ) .output(APOLLO_CONTACT_SCHEMA.array()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) const [contactIds, ownerId] = await Promise.all([ resolveApolloReferences(input.contacts, (reference) => resolveApolloContact(api, reference), ), resolveApolloUser(api, input.owner), ]) // Keeps the response envelope readable while normalizing its contacts. const result = await api.request("contacts/update_owners", { method: "POST", query: { "contact_ids[]": contactIds, owner_id: ownerId }, responseSchema: CONTACTS_RESPONSE_SCHEMA, }) return result.contacts.map(toApolloContact) }) /** Lists contact stages configured in the connected Apollo workspace. */ export const listApolloContactStages = defineAction( "List Apollo contact stages", ) .describe("Lists contact stages for semantic stage selection.") .account("apollo", apolloAccountOptions("contact_stages_list")) .input(z.object({})) .output(APOLLO_STAGE_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account }) => { // Keeps the response envelope readable while normalizing its stages. const result = await getApolloApi(account).request("contact_stages", { responseSchema: CONTACT_STAGES_RESPONSE_SCHEMA, }) return result.contact_stages.map(toApolloStage) }) /** * Converts shared contact mutation fields to Apollo's provider shape. * * @param api - Authenticated Apollo client. * @param input - Parsed contact mutation fields. */ async function toContactMutationBody( api: ReturnType, input: z.output, ) { return { account_id: input.associatedAccount ? await resolveApolloAccount(api, input.associatedAccount) : undefined, contact_stage_id: input.stage ? await resolveApolloContactStage(api, input.stage) : undefined, corporate_phone: input.corporatePhone, direct_phone: input.directPhone, email: input.email, first_name: input.firstName, home_phone: input.homePhone, label_names: input.listNames, last_name: input.lastName, mobile_phone: input.mobilePhone, organization_name: input.organizationName, other_phone: input.otherPhone, present_raw_address: input.presentAddress, title: input.title, typed_custom_fields: input.customFields, website_url: input.websiteUrl, } } /** * Converts bulk-safe contact fields to Apollo's provider shape. * * @param input - Parsed bulk-safe contact fields. */ function toBulkContactFields( input: z.output, ) { return { email: input.email, first_name: input.firstName, last_name: input.lastName, linkedin_url: input.linkedinUrl, organization_name: input.organizationName, present_raw_address: input.presentAddress, title: input.title, typed_custom_fields: input.customFields, } } /** * Returns whether any mutable contact field was provided. * * @param input - Parsed contact input. */ function hasContactMutation(input: Record) { return Object.entries(input).some( ([name, value]) => name !== "contact" && name !== "runDedupe" && value !== undefined, ) } /** * Returns whether a bulk contact update includes a mutable field. * * @param input - Parsed per-contact bulk update. */ function hasBulkContactMutation( input: z.input & { contact: unknown }, ) { return Object.entries(input).some( ([name, value]) => name !== "contact" && value !== undefined, ) }