import * as z from "zod" import { defineAction } from "../../../automation/actions" import { integrationScope as scope } from "../../../automation/integrations" import { apolloAccountOptions, apolloApiKeyAccountOptions, ApolloApiError, getApolloApi, } from "../lib/api" import { resolveApolloAccount, resolveApolloContact, resolveApolloUser, } from "../lib/references" import { APOLLO_PAGINATION_SCHEMA, APOLLO_PROVIDER_PAGINATION_SCHEMA, APOLLO_REFERENCE_SCHEMA, toApolloPagination, } from "../lib/schemas" const APOLLO_PROVIDER_CURRENCY_SCHEMA = z.looseObject({ iso_code: z.string().nullish(), name: z.string().nullish(), symbol: z.string().nullish(), }) const APOLLO_PROVIDER_DEAL_SCHEMA = z.looseObject({ account_id: z.string().nullish(), actual_close_date: z.string().nullish(), amount: z.number().nullish(), amount_in_team_currency: z.number().nullish(), closed_date: z.string().nullish(), closed_lost_reason: z.string().nullish(), closed_won_reason: z.string().nullish(), created_at: z.string().nullish(), created_by_id: z.string().nullish(), currency: APOLLO_PROVIDER_CURRENCY_SCHEMA.nullish(), deal_probability: z.number().nullish(), description: z.string().nullish(), forecast_category: z.string().nullish(), forecasted_revenue: z.number().nullish(), id: z.string(), is_closed: z.boolean().nullish(), is_won: z.boolean().nullish(), last_activity_date: z.string().nullish(), name: z.string(), next_step: z.string().nullish(), next_step_date: z.string().nullish(), opportunity_pipeline_id: z.string().nullish(), opportunity_stage_id: z.string().nullish(), owner_id: z.string().nullish(), source: z.string().nullish(), stage_name: z.string().nullish(), stage_updated_at: z.string().nullish(), team_id: z.string().nullish(), typed_custom_fields: z.record(z.string(), z.json()).nullish(), }) const APOLLO_DEAL_SCHEMA = z.object({ accountId: z.string().optional(), actualCloseDate: z.string().optional(), amount: z.number().optional(), amountInTeamCurrency: z.number().optional(), closedDate: z.string().optional(), closedLostReason: z.string().optional(), closedWonReason: z.string().optional(), createdAt: z.string().optional(), createdById: z.string().optional(), currency: z .object({ isoCode: z.string().optional(), name: z.string().optional(), symbol: z.string().optional(), }) .optional(), customFields: z.record(z.string(), z.json()), dealProbability: z.number().optional(), description: z.string().optional(), forecastCategory: z.string().optional(), forecastedRevenue: z.number().optional(), id: z.string(), isClosed: z.boolean().optional(), isWon: z.boolean().optional(), lastActivityAt: z.string().optional(), name: z.string(), nextStep: z.string().optional(), nextStepDate: z.string().optional(), pipelineId: z.string().optional(), stageId: z.string().optional(), stageName: z.string().optional(), stageUpdatedAt: z.string().optional(), ownerId: z.string().optional(), source: z.string().optional(), teamId: z.string().optional(), }) const APOLLO_PROVIDER_DEAL_STAGE_SCHEMA = z.looseObject({ description: z.string().nullish(), display_order: z.number().int().nonnegative().nullish(), forecast_category_cd: z.string().nullish(), id: z.string(), is_closed: z.boolean().nullish(), is_won: z.boolean().nullish(), name: z.string(), opportunity_pipeline_id: z.string().nullish(), probability: z.number().nullish(), team_id: z.string().nullish(), type: z.string().nullish(), }) const APOLLO_DEAL_STAGE_SCHEMA = z.object({ description: z.string().optional(), displayOrder: z.number().int().nonnegative().optional(), forecastCategory: z.string().optional(), id: z.string(), isClosed: z.boolean().optional(), isWon: z.boolean().optional(), name: z.string(), pipelineId: z.string().optional(), probability: z.number().optional(), teamId: z.string().optional(), type: z.string().optional(), }) const DEAL_RESPONSE_SCHEMA = z.looseObject({ opportunity: APOLLO_PROVIDER_DEAL_SCHEMA, }) const DEALS_RESPONSE_SCHEMA = z.looseObject({ opportunities: APOLLO_PROVIDER_DEAL_SCHEMA.array().prefault([]), pagination: APOLLO_PROVIDER_PAGINATION_SCHEMA, }) const CONTACT_DEALS_RESPONSE_SCHEMA = z.looseObject({ opportunities: APOLLO_PROVIDER_DEAL_SCHEMA.array().prefault([]), }) const DEAL_STAGES_RESPONSE_SCHEMA = z.looseObject({ opportunity_stages: APOLLO_PROVIDER_DEAL_STAGE_SCHEMA.array().prefault([]), }) const DEAL_PAGE_SCHEMA = z.object({ deals: APOLLO_DEAL_SCHEMA.array(), pagination: APOLLO_PAGINATION_SCHEMA, }) const DEAL_MUTATION_FIELDS = { amount: z .union([ z.number().nonnegative(), z .string() .trim() .regex(/^\d+(?:\.\d+)?$/), ]) .optional(), closedDate: z.iso.date().optional(), customFields: z.record(z.string(), z.json()).optional(), name: z.string().trim().min(1).optional(), owner: APOLLO_REFERENCE_SCHEMA.optional(), stage: APOLLO_REFERENCE_SCHEMA.optional(), } const UPDATE_DEAL_INPUT_SCHEMA = z .object({ deal: APOLLO_REFERENCE_SCHEMA, ...DEAL_MUTATION_FIELDS }) .refine( (input) => input.amount !== undefined || input.closedDate !== undefined || input.customFields !== undefined || input.name !== undefined || input.owner !== undefined || input.stage !== undefined, "Provide at least one deal field to update.", ) /** Lists deals associated with one saved Apollo contact. */ export const listApolloContactDeals = defineAction("List Apollo contact deals") .describe("Lists deals associated with one saved contact.") .account("apollo", apolloApiKeyAccountOptions()) .input(z.object({ contact: APOLLO_REFERENCE_SCHEMA })) .output(z.object({ deals: APOLLO_DEAL_SCHEMA.array() })) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) return { deals: ( await api.request( `contacts/${encodeURIComponent(await resolveApolloContact(api, input.contact))}/opportunities`, { body: {}, responseSchema: CONTACT_DEALS_RESPONSE_SCHEMA }, ) ).opportunities.map(toApolloDeal), } }) /** Creates a deal in Apollo. */ export const createApolloDeal = defineAction("Create Apollo deal") .describe("Creates a deal with optional account, owner, stage, and value.") .account( "apollo", apolloAccountOptions( scope.and( "opportunity_write", "account_read", "accounts_search", "users_list", "opportunity_stages_list", ), ), ) .input( z.object({ ...DEAL_MUTATION_FIELDS, account: APOLLO_REFERENCE_SCHEMA.optional(), name: z.string().trim().min(1), }), ) .output(APOLLO_DEAL_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) return toApolloDeal( ( await api.request("opportunities", { body: await toDealMutationBody(api, input, input.account), responseSchema: DEAL_RESPONSE_SCHEMA, }) ).opportunity, ) }) /** Lists one page of deals in Apollo. */ export const listApolloDeals = defineAction("List Apollo deals") .describe("Lists one page of deals with optional provider sorting.") .account("apollo", apolloAccountOptions("opportunities_list")) .input( z.object({ page: z.number().int().min(1).prefault(1), perPage: z.number().int().min(1).max(100).prefault(100), sortBy: z.enum(["amount", "isClosed", "isWon"]).optional(), }), ) .output(DEAL_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const result = await getApolloApi(account).request("opportunities/search", { query: { page: input.page, per_page: input.perPage, sort_by_field: input.sortBy === "isClosed" ? "is_closed" : input.sortBy === "isWon" ? "is_won" : input.sortBy, }, responseSchema: DEALS_RESPONSE_SCHEMA, }) return { deals: result.opportunities.map(toApolloDeal), pagination: toApolloPagination(result.pagination), } }) /** Gets one Apollo deal by ID, exact name, or explicit ID reference. */ export const getApolloDeal = defineAction("Get Apollo deal") .describe("Gets one deal using a semantic reference.") .account( "apollo", apolloAccountOptions(scope.and("opportunity_read", "opportunities_list")), ) .input(z.object({ deal: APOLLO_REFERENCE_SCHEMA })) .output(APOLLO_DEAL_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) return toApolloDeal( ( await api.request( `opportunities/${encodeURIComponent(await resolveApolloDeal(api, input.deal))}`, { responseSchema: DEAL_RESPONSE_SCHEMA }, ) ).opportunity, ) }) /** Updates selected fields on one Apollo deal. */ export const updateApolloDeal = defineAction("Update Apollo deal") .describe("Updates selected fields on a deal using semantic references.") .account( "apollo", apolloAccountOptions( scope.and( "opportunity_update", "opportunity_read", "opportunities_list", "users_list", "opportunity_stages_list", ), ), ) .input(UPDATE_DEAL_INPUT_SCHEMA) .output(APOLLO_DEAL_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) return toApolloDeal( ( await api.request( `opportunities/${encodeURIComponent(await resolveApolloDeal(api, input.deal))}`, { body: await toDealMutationBody(api, input), method: "PATCH", responseSchema: DEAL_RESPONSE_SCHEMA, }, ) ).opportunity, ) }) /** Lists deal stages available in Apollo. */ export const listApolloDealStages = defineAction("List Apollo deal stages") .describe("Lists every deal stage in the connected workspace.") .account("apollo", apolloAccountOptions("opportunity_stages_list")) .output(z.object({ stages: APOLLO_DEAL_STAGE_SCHEMA.array() })) .retry({ replaySafety: "safe" }) .handler(async ({ account }) => { return { stages: ( await getApolloApi(account).request("opportunity_stages", { responseSchema: DEAL_STAGES_RESPONSE_SCHEMA, }) ).opportunity_stages.map(toApolloDealStage), } }) /** * Resolves deal associations and encodes one Apollo mutation body. * * @param api - Authenticated Apollo client. * @param input - Public deal mutation fields. * @param account - Optional account association supported during creation. */ async function toDealMutationBody( api: ReturnType, input: | z.output | { amount?: number | string closedDate?: string customFields?: Record name: string owner?: z.output stage?: z.output }, account?: z.output, ) { const [accountId, ownerId, stageId] = await Promise.all([ account ? resolveApolloAccount(api, account) : undefined, input.owner ? resolveApolloUser(api, input.owner) : undefined, input.stage ? resolveApolloDealStage(api, input.stage) : undefined, ]) return { account_id: accountId, amount: input.amount === undefined ? undefined : String(input.amount), closed_date: input.closedDate, name: input.name, opportunity_stage_id: stageId, owner_id: ownerId, typed_custom_fields: input.customFields, } } /** * Resolves a deal by explicit ID, direct ID, or unique exact name. * * @param api - Authenticated Apollo client. * @param reference - Semantic or explicit deal reference. */ async function resolveApolloDeal( api: ReturnType, reference: z.output, ) { if (typeof reference !== "string") return reference.id try { return ( await api.request(`opportunities/${encodeURIComponent(reference)}`, { responseSchema: DEAL_RESPONSE_SCHEMA, }) ).opportunity.id } catch (error) { if ( !(error instanceof ApolloApiError) || (error.status !== 404 && error.status !== 422) ) { throw error } } const matches = (await listAllApolloDeals(api)).filter( ({ name }) => name.localeCompare(reference, undefined, { sensitivity: "accent" }) === 0, ) if (matches.length === 1) return matches[0]!.id throw new Error( matches.length === 0 ? `Apollo deal not found: ${reference}` : `Apollo deal reference is ambiguous: ${reference}. Matching IDs: ${matches.map(({ id }) => id).join(", ")}`, ) } /** * Lists every Apollo deal for semantic reference resolution. * * @param api - Authenticated Apollo client. */ async function listAllApolloDeals(api: ReturnType) { const deals: z.output[] = [] let page = 1 let totalPages = 1 do { const result = await api.request("opportunities/search", { query: { page, per_page: 100 }, responseSchema: DEALS_RESPONSE_SCHEMA, }) deals.push(...result.opportunities) totalPages = result.pagination.total_pages page += 1 } while (page <= totalPages) return deals } /** * Resolves a deal stage by explicit ID, ID, or unique exact name. * * @param api - Authenticated Apollo client. * @param reference - Semantic or explicit deal-stage reference. */ async function resolveApolloDealStage( api: ReturnType, reference: z.output, ) { if (typeof reference !== "string") return reference.id const result = await api.request("opportunity_stages", { responseSchema: DEAL_STAGES_RESPONSE_SCHEMA, }) const direct = result.opportunity_stages.find(({ id }) => id === reference) if (direct) return direct.id const matches = result.opportunity_stages.filter( ({ name }) => name.localeCompare(reference, undefined, { sensitivity: "accent" }) === 0, ) if (matches.length === 1) return matches[0]!.id throw new Error( matches.length === 0 ? `Apollo deal stage not found: ${reference}` : `Apollo deal stage reference is ambiguous: ${reference}. Matching IDs: ${matches.map(({ id }) => id).join(", ")}`, ) } /** * Converts one provider deal to its public representation. * * @param value - Provider deal response. */ function toApolloDeal(value: z.output) { return APOLLO_DEAL_SCHEMA.parse({ accountId: value.account_id ?? undefined, actualCloseDate: value.actual_close_date ?? undefined, amount: value.amount ?? undefined, amountInTeamCurrency: value.amount_in_team_currency ?? undefined, closedDate: value.closed_date ?? undefined, closedLostReason: value.closed_lost_reason ?? undefined, closedWonReason: value.closed_won_reason ?? undefined, createdAt: value.created_at ?? undefined, createdById: value.created_by_id ?? undefined, currency: value.currency ? { isoCode: value.currency.iso_code ?? undefined, name: value.currency.name ?? undefined, symbol: value.currency.symbol ?? undefined, } : undefined, customFields: value.typed_custom_fields ?? {}, dealProbability: value.deal_probability ?? undefined, description: value.description ?? undefined, forecastCategory: value.forecast_category ?? undefined, forecastedRevenue: value.forecasted_revenue ?? undefined, id: value.id, isClosed: value.is_closed ?? undefined, isWon: value.is_won ?? undefined, lastActivityAt: value.last_activity_date ?? undefined, name: value.name, nextStep: value.next_step ?? undefined, nextStepDate: value.next_step_date ?? undefined, ownerId: value.owner_id ?? undefined, pipelineId: value.opportunity_pipeline_id ?? undefined, source: value.source ?? undefined, stageId: value.opportunity_stage_id ?? undefined, stageName: value.stage_name ?? undefined, stageUpdatedAt: value.stage_updated_at ?? undefined, teamId: value.team_id ?? undefined, }) } /** * Converts one provider deal stage to its public representation. * * @param value - Provider deal-stage response. */ function toApolloDealStage( value: z.output, ) { return APOLLO_DEAL_STAGE_SCHEMA.parse({ description: value.description ?? undefined, displayOrder: value.display_order ?? undefined, forecastCategory: value.forecast_category_cd ?? undefined, id: value.id, isClosed: value.is_closed ?? undefined, isWon: value.is_won ?? undefined, name: value.name, pipelineId: value.opportunity_pipeline_id ?? undefined, probability: value.probability ?? undefined, teamId: value.team_id ?? undefined, type: value.type ?? undefined, }) }