import * as z from "zod" import { defineAction } from "../../../automation/actions" import { integrationScope as scope } from "../../../automation/integrations" import { apolloAccountOptions, getApolloApi, type ApolloApi } from "../lib/api" import { resolveApolloAccount, resolveApolloContact, resolveApolloReferences, } from "../lib/references" import { APOLLO_LIST_SCHEMA, APOLLO_PROVIDER_LIST_SCHEMA, APOLLO_REFERENCE_SCHEMA, toApolloList, } from "../lib/schemas" const APOLLO_LIST_RECORD_TYPE_SCHEMA = z.enum(["accounts", "contacts"]) const APOLLO_LIST_RESPONSE_SCHEMA = z.looseObject({ label: APOLLO_PROVIDER_LIST_SCHEMA, }) const APOLLO_PROGRESS_JOB_SCHEMA = z.object({ /** Provider batch size, when known. */ batchSize: z.number().int().nullable().optional(), /** Stable IDs of records being processed. */ entityIds: z.string().array(), /** Stable Apollo background-job identifier. */ jobId: z.string(), /** Provider job type. */ jobType: z.string(), /** Current provider progress value. */ progress: z.number().int(), /** Apollo user that started the job. */ userId: z.string().optional(), }) const APOLLO_PROVIDER_PROGRESS_JOB_SCHEMA = z.looseObject({ batch_size: z.number().int().nullable().optional(), entity_ids: z.string().array().prefault([]), id: z.string(), job_type: z.string(), progress: z.number().int().prefault(0), user_id: z.string().optional(), }) const APOLLO_LIST_MEMBERSHIP_RESPONSE_SCHEMA = z.looseObject({ entity_progress_job: APOLLO_PROVIDER_PROGRESS_JOB_SCHEMA.optional(), labels: APOLLO_PROVIDER_LIST_SCHEMA.array().optional(), message: z.string().optional(), }) const APOLLO_LIST_MEMBERSHIP_RESULT_SCHEMA = z.object({ /** Background job created when asynchronous processing is requested. */ job: APOLLO_PROGRESS_JOB_SCHEMA.optional(), /** Lists changed by a synchronous request. */ lists: APOLLO_LIST_SCHEMA.array(), /** Provider explanation when no records or lists were changed. */ message: z.string().optional(), }) const LIST_MUTATION_INPUT_SCHEMA = z.object({ /** Process a large change in the background. */ async: z.boolean().optional(), /** Contact emails, account domains, names, or IDs. */ records: APOLLO_REFERENCE_SCHEMA.array().min(1), /** Names of Apollo lists to change. */ listNames: z.string().trim().min(1).array().min(1), /** Type of records being changed. */ recordType: APOLLO_LIST_RECORD_TYPE_SCHEMA, }) /** Lists every Apollo list visible to the connected account. */ export const listApolloLists = defineAction("List Apollo lists") .describe("Lists contact and account lists visible in Apollo.") .account("apollo", apolloAccountOptions("tags_list")) .input( z.object({ /** Limit results to lists containing this type of record. */ recordType: APOLLO_LIST_RECORD_TYPE_SCHEMA.optional(), }), ) .output(z.object({ lists: APOLLO_LIST_SCHEMA.array() })) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ({ lists: (await getApolloLists(getApolloApi(account))) .filter( ({ modality }) => modality === input.recordType || (input.recordType === undefined && (modality === "accounts" || modality === "contacts")), ) .map(toApolloList), })) /** Creates a named Apollo contact or account list. */ export const createApolloList = defineAction("Create Apollo list") .describe("Creates a named list for Apollo contacts or accounts.") .account("apollo", apolloAccountOptions("lists_create")) .input( z .object({ /** Mark an account list as a Book of Business list. */ bookOfBusiness: z.boolean().optional(), /** Unique list name within the selected record type. */ name: z.string().trim().min(1), /** Type of records stored in the list. */ recordType: APOLLO_LIST_RECORD_TYPE_SCHEMA, }) .superRefine((input, context) => { if (input.bookOfBusiness && input.recordType !== "accounts") { context.addIssue({ code: "custom", message: "Book of Business is only available for account lists.", path: ["bookOfBusiness"], }) } }), ) .output(APOLLO_LIST_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { label } = await getApolloApi(account).request("/labels", { body: { ...(input.bookOfBusiness !== undefined && { book_of_business: input.bookOfBusiness, }), modality: input.recordType, name: input.name, }, responseSchema: APOLLO_LIST_RESPONSE_SCHEMA, }) return toApolloList(label) }) /** Updates an Apollo list selected by exact name or canonical ID. */ export const updateApolloList = defineAction("Update Apollo list") .describe("Renames a list or changes its Book of Business setting.") .account( "apollo", apolloAccountOptions(scope.and("lists_update", "tags_list")), ) .input( z .object({ /** New Book of Business setting for an account list. */ bookOfBusiness: z.boolean().optional(), /** Exact list name or ID; `{ id }` skips the list lookup. */ list: APOLLO_REFERENCE_SCHEMA, /** New list name. */ name: z.string().trim().min(1).optional(), /** Record type used to disambiguate lists with the same name. */ recordType: APOLLO_LIST_RECORD_TYPE_SCHEMA.optional(), }) .superRefine((input, context) => { if (input.name === undefined && input.bookOfBusiness === undefined) { context.addIssue({ code: "custom", message: "Provide name or bookOfBusiness.", }) } if (input.bookOfBusiness && input.recordType === "contacts") { context.addIssue({ code: "custom", message: "Book of Business is only available for account lists.", path: ["bookOfBusiness"], }) } }), ) .output(APOLLO_LIST_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) const { label } = await api.request( `/labels/${encodeURIComponent(await resolveApolloListId(api, input.list, input.recordType))}`, { body: { ...(input.bookOfBusiness !== undefined && { book_of_business: input.bookOfBusiness, }), ...(input.name !== undefined && { name: input.name }), }, method: "PATCH", responseSchema: APOLLO_LIST_RESPONSE_SCHEMA, }, ) return toApolloList(label) }) /** Adds Apollo records to one or more provider-native list names. */ export const addRecordsToApolloLists = defineAction( "Add records to Apollo lists", ) .describe( "Adds contacts or accounts to named lists, creating missing lists as Apollo permits.", ) .account( "apollo", apolloAccountOptions( scope.and( "lists_add_entities", "contact_read", "contacts_search", "account_read", "accounts_search", ), ), ) .input(LIST_MUTATION_INPUT_SCHEMA) .output(APOLLO_LIST_MEMBERSHIP_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) return mutateListMembership( api, "/labels/add_entity_ids_to_label_names", input, await resolveListRecords(api, input.recordType, input.records), ) }) /** Removes Apollo records from one or more provider-native list names. */ export const removeRecordsFromApolloLists = defineAction( "Remove records from Apollo lists", ) .describe("Removes contacts or accounts from named Apollo lists.") .account( "apollo", apolloAccountOptions( scope.and( "lists_remove_entities", "contact_read", "contacts_search", "account_read", "accounts_search", ), ), ) .input(LIST_MUTATION_INPUT_SCHEMA) .output(APOLLO_LIST_MEMBERSHIP_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) return mutateListMembership( api, "/labels/remove_entity_ids_from_label_names", input, await resolveListRecords(api, input.recordType, input.records), ) }) /** * Lists the complete provider-owned list collection. * * @param api - Authenticated Apollo API client. */ async function getApolloLists(api: ApolloApi) { return api.request("/labels", { responseSchema: APOLLO_PROVIDER_LIST_SCHEMA.array(), }) } /** * Resolves an exact Apollo list name or ID to its canonical ID. * * @param api - Authenticated Apollo API client. * @param reference - Exact Apollo list name or ID. * @param recordType - Optional list modality used to disambiguate names. */ async function resolveApolloListId( api: ApolloApi, reference: z.output, recordType?: z.output, ) { if (typeof reference !== "string") return reference.id const lists = (await getApolloLists(api)).filter( ({ modality }) => recordType === undefined || modality === recordType, ) const idMatch = lists.find(({ id }) => id === reference) if (idMatch) return idMatch.id const normalizedReference = reference.trim().toLowerCase() const nameMatches = lists.filter( ({ name }) => name.trim().toLowerCase() === normalizedReference, ) if (nameMatches.length === 1) { return nameMatches[0]!.id } if (nameMatches.length > 1) { throw new Error( `Apollo list "${reference}" is ambiguous; matching IDs: ${nameMatches.map(({ id }) => id).join(", ")}.`, ) } throw new Error(`Apollo list "${reference}" was not found.`) } /** * Adds or removes records using Apollo's provider-native list names. * * @param api - Authenticated Apollo API client. * @param path - Apollo list-membership endpoint. * @param input - Parsed list and record input. * @param recordIds - Resolved canonical Apollo record IDs. */ async function mutateListMembership( api: ApolloApi, path: | "/labels/add_entity_ids_to_label_names" | "/labels/remove_entity_ids_from_label_names", input: z.output, recordIds: string[], ) { const result = await api.request(path, { body: { ...(input.async !== undefined && { async: input.async }), entity_ids: recordIds, label_names: input.listNames, modality: input.recordType, }, responseSchema: APOLLO_LIST_MEMBERSHIP_RESPONSE_SCHEMA, }) return { ...(result.entity_progress_job && { job: toApolloProgressJob(result.entity_progress_job), }), lists: (result.labels ?? []).map(toApolloList), ...(result.message && { message: result.message }), } } /** * Resolves contacts or accounts used by a list membership change. * * @param api - Authenticated Apollo API client. * @param recordType - Whether the references select contacts or accounts. * @param records - Semantic Apollo record references. */ function resolveListRecords( api: ApolloApi, recordType: z.output, records: z.output[], ) { return resolveApolloReferences(records, (record) => recordType === "contacts" ? resolveApolloContact(api, record) : resolveApolloAccount(api, record), ) } /** * Normalizes an Apollo asynchronous mutation job. * * @param job - Provider background-job response. */ function toApolloProgressJob( job: z.output, ) { return { batchSize: job.batch_size, entityIds: job.entity_ids, jobId: job.id, jobType: job.job_type, progress: job.progress, userId: job.user_id, } }