diff --git a/packages/activecampaign/behaviour.test.ts b/packages/activecampaign/behaviour.test.ts new file mode 100644 index 000000000..f6ee532a0 --- /dev/null +++ b/packages/activecampaign/behaviour.test.ts @@ -0,0 +1,400 @@ +import { AuthMissingError } from 'corsair/core'; +import { evictChildren } from './endpoints/persist'; +import { resolveAccount } from './endpoints/shared'; +import { activecampaign } from './index'; + +/** + * Behavioural coverage for the things routing checks cannot see: what goes in + * the request body, which store a response is mirrored into, and what reaches + * the event log. + * + * `routing.test.ts` proves every operation hits the right URL with the right + * method. This file proves a representative operation of each shape does the + * right thing once it gets there - the write envelope, the cache target, the + * audit payload - which is where a silent contract error would otherwise sit. + */ + +const ACCOUNT = 'example'; +const TOKEN = 'test-token-value'; + +interface Captured { + url: string; + method: string; + body: unknown; +} + +type SearchResult = Array<{ entity_id?: string } | undefined>; + +type Store = { + rows: Map>; + upsertByEntityId: jest.Mock, [string, unknown]>; + deleteByEntityId: jest.Mock, [string]>; + search: jest.Mock, [unknown]>; +}; + +function makeStore(seed: Array> = []): Store { + const rows = new Map>(); + for (const row of seed) rows.set(String(row.id), row); + return { + rows, + upsertByEntityId: jest.fn(async (id: string, data) => { + rows.set(id, data as Record); + return data; + }), + deleteByEntityId: jest.fn(async (id: string) => rows.delete(id)), + search: jest.fn, [unknown]>(async (options) => { + const { data } = options as { data: Record }; + return [...rows.values()] + .filter((r) => Object.entries(data).every(([k, v]) => r[k] === v)) + .map((r) => ({ entity_id: String(r.id) })); + }), + }; +} + +describe('request bodies, persistence and audit payloads', () => { + const plugin = activecampaign({ key: TOKEN, account: ACCOUNT }); + const tree = plugin.endpoints as Record< + string, + Record Promise> + >; + + /** Looks an operation up, failing loudly rather than silently skipping. */ + function op(group: string, leaf: string) { + const fn = tree[group]?.[leaf]; + if (!fn) throw new Error(`No such operation: ${group}.${leaf}`); + return fn; + } + + /** The nth captured request, asserted to exist. */ + function call(index = 0): Captured { + const c = calls[index]; + if (!c) throw new Error(`No request captured at index ${index}`); + return c; + } + + const originalFetch = globalThis.fetch; + let calls: Captured[] = []; + let warn: jest.SpyInstance; + + function makeCtx(db: Record = {}) { + return { + key: TOKEN, + options: { account: ACCOUNT }, + keys: { get_account: async () => ACCOUNT }, + db, + $getAccountId: async () => 'test-account', + database: undefined, + }; + } + + function respondWith(body: unknown) { + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method ?? 'GET', + body: + typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body, + }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + } + + beforeEach(() => { + calls = []; + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + respondWith({ meta: { total: '0' } }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + warn.mockRestore(); + }); + + describe('write bodies', () => { + it('wraps a create body in the resource envelope', async () => { + respondWith({ tag: { id: '9', tag: 'vip' } }); + await op('tags', 'create')(makeCtx(), { + tag: 'vip', + tagType: 'contact', + }); + + expect(calls).toHaveLength(1); + expect(call().method).toBe('POST'); + expect(call().body).toEqual({ + tag: { tag: 'vip', tagType: 'contact' }, + }); + }); + + /** + * ActiveCampaign distinguishes an absent field from an explicit null, so + * an omitted optional must not be serialised at all. + */ + it('omits undefined optionals from the body rather than sending them', async () => { + respondWith({ tag: { id: '9' } }); + await op('tags', 'create')(makeCtx(), { tag: 'vip', tagType: 'contact' }); + + const body = call().body as { tag: Record }; + expect(Object.keys(body.tag)).not.toContain('description'); + }); + + /** + * Omitting this lets ActiveCampaign apply its own default of true, which + * mails the account's latest broadcast to every new subscriber. + */ + it('sends the fail-safe send_last_broadcast default explicitly', async () => { + respondWith({ list: { id: '3' } }); + await op('lists', 'create')(makeCtx(), { + name: 'Newsletter', + stringid: 'newsletter', + sender_url: 'https://example.com', + sender_reminder: 'You signed up', + }); + + const body = call().body as { list: Record }; + expect(body.list.send_last_broadcast).toBe(false); + }); + + /** Same reasoning, one API surface over: a bulk import can send a lot of mail. */ + it('excludes automations on bulk import unless asked otherwise', async () => { + respondWith({ Success: 1 }); + await op('imports', 'createBulk')(makeCtx(), { + contacts: [{ email: 'someone@example.com' }], + }); + + const body = call().body as Record; + expect(body.exclude_automations).toBe(true); + }); + + it('pages the account upsert lookup until an exact name match', async () => { + const firstPage = Array.from({ length: 100 }, (_, i) => ({ + id: String(i + 1), + name: `other-${i}`, + })); + const pages = [ + { accounts: firstPage }, + { accounts: [{ id: '101', name: 'Acme' }] }, + { account: { id: '101', name: 'Acme' } }, + ]; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method ?? 'GET', + body: + typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body, + }); + return new Response(JSON.stringify(pages.shift() ?? {}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + await op('accounts', 'upsert')(makeCtx(), { name: 'Acme' }); + + const gets = calls.filter((c) => c.method.toUpperCase() === 'GET'); + expect(gets).toHaveLength(2); + expect(gets[1]?.url).toContain('offset=100'); + expect(call(2).method.toUpperCase()).toBe('PUT'); + expect(call(2).url).toContain('/accounts/101'); + }); + }); + + describe('persistence targets', () => { + it('mirrors a created row into its own store and no other', async () => { + respondWith({ tag: { id: '9', tag: 'vip' } }); + const tags = makeStore(); + const contacts = makeStore(); + await op('tags', 'create')(makeCtx({ tags, contacts }), { + tag: 'vip', + tagType: 'contact', + }); + + expect(tags.upsertByEntityId).toHaveBeenCalledTimes(1); + expect(tags.upsertByEntityId.mock.calls[0]?.[0]).toBe('9'); + expect(contacts.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('mirrors every row of a listed page', async () => { + respondWith({ + tags: [{ id: '1' }, { id: '2' }, { id: '3' }], + meta: { total: '3' }, + }); + const tags = makeStore(); + await op('tags', 'list')(makeCtx({ tags }), {}); + + expect(tags.upsertByEntityId).toHaveBeenCalledTimes(3); + expect([...tags.rows.keys()].sort()).toEqual(['1', '2', '3']); + }); + + /** A read must never remove a row: ActiveCampaign archives, it rarely deletes. */ + it('does not evict on a read', async () => { + respondWith({ tags: [{ id: '1' }], meta: { total: '1' } }); + const tags = makeStore([{ id: '99' }]); + await op('tags', 'list')(makeCtx({ tags }), {}); + + expect(tags.deleteByEntityId).not.toHaveBeenCalled(); + expect(tags.rows.has('99')).toBe(true); + }); + + it('evicts on an explicit delete', async () => { + respondWith({}); + const tags = makeStore([{ id: '5' }]); + await op('tags', 'delete')(makeCtx({ tags }), { id: '5' }); + + expect(tags.deleteByEntityId).toHaveBeenCalledWith('5'); + expect(tags.rows.has('5')).toBe(false); + }); + + /** + * Deleting a tag removes it from every contact upstream, so the cached + * associations must go too - otherwise the mirror keeps describing a + * link that no longer exists. + */ + it('evicts dependent contactTags when a tag is deleted', async () => { + respondWith({}); + const tags = makeStore([{ id: '5' }]); + const contactTags = makeStore([ + { id: '100', tag: '5' }, + { id: '101', tag: '5' }, + { id: '102', tag: '6' }, + ]); + await op('tags', 'delete')(makeCtx({ tags, contactTags }), { id: '5' }); + + expect(contactTags.deleteByEntityId).toHaveBeenCalledTimes(2); + expect([...contactTags.rows.keys()]).toEqual(['102']); + }); + + it('evicts dependent fieldValues when a custom field is deleted', async () => { + respondWith({}); + const fields = makeStore([{ id: '7' }]); + const fieldValues = makeStore([ + { id: '200', field: '7' }, + { id: '201', field: '8' }, + ]); + await op('fields', 'delete')(makeCtx({ fields, fieldValues }), { + id: '7', + }); + + expect(fieldValues.deleteByEntityId).toHaveBeenCalledWith('200'); + expect([...fieldValues.rows.keys()]).toEqual(['201']); + }); + + /** A mirror failure must never fail the API call that already succeeded. */ + it('still resolves when the store write throws', async () => { + respondWith({ tag: { id: '9' } }); + const tags = makeStore(); + tags.upsertByEntityId.mockRejectedValueOnce(new Error('db down')); + + await expect( + op('tags', 'create')(makeCtx({ tags }), { + tag: 'vip', + tagType: 'contact', + }), + ).resolves.toBeDefined(); + expect(warn).toHaveBeenCalled(); + }); + }); + + describe('credential resolution', () => { + it('prefers the plugin option over stored key material', async () => { + const account = await resolveAccount({ + options: { account: 'from-options' }, + keys: { get_account: async () => 'from-keys' }, + }); + expect(account).toBe('from-options'); + }); + + it('falls back to stored key material', async () => { + const account = await resolveAccount({ + options: {}, + keys: { get_account: async () => 'from-keys' }, + }); + expect(account).toBe('from-keys'); + }); + + /** + * Returning '' here would build a request against `https://.api-us1.com` + * and surface a missing credential as a confusing transport failure. + */ + it.each([ + [{ options: {}, keys: { get_account: async () => null } }], + [{ options: {}, keys: { get_account: async () => '' } }], + [{ options: {}, keys: {} }], + [{}], + ])( + 'raises AuthMissingError when the account is absent: %#', + async (ctx) => { + await expect(resolveAccount(ctx)).rejects.toBeInstanceOf( + AuthMissingError, + ); + }, + ); + + it('names the plugin and the missing field on the error', async () => { + await expect(resolveAccount({})).rejects.toMatchObject({ + pluginId: 'activecampaign', + authType: 'account', + }); + }); + }); +}); + +describe('evictChildren', () => { + let warn: jest.SpyInstance; + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => warn.mockRestore()); + + it('does nothing when the store cannot search', async () => { + const store = { upsertByEntityId: jest.fn(), deleteByEntityId: jest.fn() }; + await expect( + evictChildren(store, 'tag', '5', 'contactTag'), + ).resolves.toBeUndefined(); + expect(store.deleteByEntityId).not.toHaveBeenCalled(); + }); + + it('does nothing when the parent id is empty', async () => { + const store = makeStoreForChildren(); + await evictChildren(store, 'tag', '', 'contactTag'); + expect(store.search).not.toHaveBeenCalled(); + }); + + it('survives a search that throws', async () => { + const store = makeStoreForChildren(); + store.search.mockRejectedValueOnce(new Error('query failed')); + await expect( + evictChildren(store, 'tag', '5', 'contactTag'), + ).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); + + it('keeps deleting after one child fails to evict', async () => { + const store = makeStoreForChildren(); + store.search.mockResolvedValueOnce([ + { entity_id: 'a' }, + { entity_id: 'b' }, + ]); + store.deleteByEntityId.mockRejectedValueOnce(new Error('locked')); + await evictChildren(store, 'tag', '5', 'contactTag'); + expect(store.deleteByEntityId).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenCalled(); + }); + + it('skips rows with no usable entity id', async () => { + const store = makeStoreForChildren(); + store.search.mockResolvedValueOnce([{}, { entity_id: '' }, undefined]); + await evictChildren(store, 'tag', '5', 'contactTag'); + expect(store.deleteByEntityId).not.toHaveBeenCalled(); + }); +}); + +function makeStoreForChildren() { + return { + upsertByEntityId: jest.fn(), + deleteByEntityId: jest.fn, [string]>(async () => true), + search: jest.fn, [unknown]>(async () => []), + }; +} diff --git a/packages/activecampaign/client.test.ts b/packages/activecampaign/client.test.ts new file mode 100644 index 000000000..e6ee18c69 --- /dev/null +++ b/packages/activecampaign/client.test.ts @@ -0,0 +1,124 @@ +import { ActiveCampaignAPIError } from './client'; + +/** + * `request` may hand fetch either a plain object or a `Headers` instance, so + * header assertions normalise both. Reading only one shape would let an + * assertion pass against an empty object. + */ +function readHeaders(init: RequestInit | undefined): Record { + const raw = init?.headers; + if (!raw) return {}; + if (raw instanceof Headers) return Object.fromEntries(raw.entries()); + if (Array.isArray(raw)) return Object.fromEntries(raw); + return Object.fromEntries( + Object.entries(raw as Record).map(([k, v]) => [k, v]), + ); +} + +describe('ActiveCampaign client', () => { + const originalFetch = globalThis.fetch; + let calls: Array<{ url: string; init?: RequestInit }>; + + beforeEach(() => { + calls = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ tags: [], meta: { total: '0' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('credential validation', () => { + it('rejects a missing API token before issuing a request', async () => { + const { makeActiveCampaignRequest } = await import('./client'); + await expect( + makeActiveCampaignRequest('tags', '', 'example'), + ).rejects.toBeInstanceOf(ActiveCampaignAPIError); + expect(calls).toHaveLength(0); + }); + + it('rejects a missing account before issuing a request', async () => { + const { makeActiveCampaignRequest } = await import('./client'); + await expect( + makeActiveCampaignRequest('tags', 'token-123', ''), + ).rejects.toBeInstanceOf(ActiveCampaignAPIError); + expect(calls).toHaveLength(0); + }); + + /** + * The account slug is interpolated into the hostname, so a value + * carrying a slash or a dot could redirect the request to another host. + */ + it.each([ + ['evil.com/', 'a slash'], + ['host.other.com', 'a dot'], + ['a b', 'a space'], + ['acct@x', 'an at sign'], + ])('rejects an account containing %s (%s)', async (account) => { + const { makeActiveCampaignRequest } = await import('./client'); + await expect( + makeActiveCampaignRequest('tags', 'token-123', account), + ).rejects.toBeInstanceOf(ActiveCampaignAPIError); + expect(calls).toHaveLength(0); + }); + + it('accepts an account of letters, digits and hyphens', async () => { + const { makeActiveCampaignRequest } = await import('./client'); + await makeActiveCampaignRequest('tags', 'token-123', 'my-account-1'); + expect(calls).toHaveLength(1); + }); + }); + + describe('request shape', () => { + it('sends the token in an Api-Token header, not a query string', async () => { + const { makeActiveCampaignRequest } = await import('./client'); + await makeActiveCampaignRequest('tags', 'token-123', 'example'); + + expect(calls).toHaveLength(1); + const headers = readHeaders(calls[0]?.init); + const headerNames = Object.keys(headers).map((h) => h.toLowerCase()); + expect(headerNames).toContain('api-token'); + expect(headers['Api-Token'] ?? headers['api-token']).toBe('token-123'); + // A key in the query string would leak into logs and referrers. + expect(calls[0]?.url).not.toContain('token-123'); + }); + + it('builds the account-specific base URL', async () => { + const { makeActiveCampaignRequest } = await import('./client'); + await makeActiveCampaignRequest('tags', 'token-123', 'example'); + expect(calls[0]?.url).toContain('https://example.api-us1.com/api/3'); + expect(calls[0]?.url).toContain('/tags'); + }); + + it('routes GraphQL to /ecom/graphql on the same host', async () => { + const { makeActiveCampaignGraphQLRequest } = await import('./client'); + await makeActiveCampaignGraphQLRequest( + '{ products { id } }', + 'token-123', + 'example', + ); + expect(calls[0]?.url).toContain('https://example.api-us1.com/api/3'); + expect(calls[0]?.url).toContain('ecom/graphql'); + const headers = readHeaders(calls[0]?.init); + expect(headers['Api-Token'] ?? headers['api-token']).toBe('token-123'); + }); + + it('uses the same auth header for REST and GraphQL', async () => { + const { makeActiveCampaignRequest, makeActiveCampaignGraphQLRequest } = + await import('./client'); + await makeActiveCampaignRequest('tags', 'token-123', 'example'); + await makeActiveCampaignGraphQLRequest('{ x }', 'token-123', 'example'); + const first = readHeaders(calls[0]?.init); + const second = readHeaders(calls[1]?.init); + expect(first['Api-Token'] ?? first['api-token']).toBe( + second['Api-Token'] ?? second['api-token'], + ); + }); + }); +}); diff --git a/packages/activecampaign/client.ts b/packages/activecampaign/client.ts new file mode 100644 index 000000000..4b03e002c --- /dev/null +++ b/packages/activecampaign/client.ts @@ -0,0 +1,155 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { request } from 'corsair/http'; + +export class ActiveCampaignAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'ActiveCampaignAPIError'; + } +} + +/** + * ActiveCampaign hosts every account on its own subdomain, so the base URL + * cannot be a constant the way it can for a single-tenant API. The account + * slug is the second half of the credential and is supplied alongside the key. + * + * @see https://developers.activecampaign.com/reference/url + */ +function buildBaseUrl(account: string): string { + return `https://${account}.api-us1.com/api/3`; +} + +/** + * ActiveCampaign allows 5 requests per second per account, shared across the + * REST and GraphQL surfaces, and answers 429 once that is exceeded. Unlike + * many APIs it returns rate-limit headers on successful responses as well as + * on rejections (`RateLimit-Limit`, `RateLimit-Remaining`), and a + * `Retry-After` on the 429 itself, which is the header the retry honours. + * + * @see https://developers.activecampaign.com/reference/rate-limits + */ +const ACTIVECAMPAIGN_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 5, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +function buildConfig(apiToken: string, account: string): OpenAPIConfig { + return { + BASE: buildBaseUrl(account), + VERSION: '3', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Api-Token': apiToken, + }, + }; +} + +/** + * Rejects a credential that is missing or that carries characters which cannot + * appear in a hostname. The account slug is interpolated into the base URL, so + * validating it here keeps a malformed value from redirecting a request to + * another host. + */ +function assertCredentials(apiToken: string, account: string): void { + if (!apiToken) { + throw new ActiveCampaignAPIError( + 'An API token is required for the ActiveCampaign integration', + 'MISSING_API_TOKEN', + ); + } + if (!account) { + throw new ActiveCampaignAPIError( + 'An account name is required for the ActiveCampaign integration - it is the subdomain of your API URL, https://.api-us1.com', + 'MISSING_ACCOUNT', + ); + } + if (!/^[a-zA-Z0-9-]+$/.test(account)) { + throw new ActiveCampaignAPIError( + 'The ActiveCampaign account name must contain only letters, numbers and hyphens', + 'INVALID_ACCOUNT', + ); + } +} + +/** + * Issues a v3 REST request with the account's `Api-Token` header, rate-limit + * retries and this plugin's error handlers. + */ +export async function makeActiveCampaignRequest( + endpoint: string, + apiToken: string, + account: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + /** Most endpoints take an object envelope; the bulk ones take a raw array. */ + body?: Record | unknown[]; + query?: Record; + } = {}, +): Promise { + assertCredentials(apiToken, account); + const { method = 'GET', body, query } = options; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query, + }; + + return await request(buildConfig(apiToken, account), requestOptions, { + rateLimitConfig: ACTIVECAMPAIGN_RATE_LIMIT_CONFIG, + }); +} + +/** + * Issues an eComm GraphQL request. + * + * ActiveCampaign puts its e-commerce catalog behind GraphQL at + * `/ecom/graphql` rather than extending the REST surface, but on the same host + * and behind the same `Api-Token` header and the same 5 req/sec budget. Both + * transports therefore share one config builder and one rate-limit config, so + * the two surfaces cannot drift apart in auth or throttling behaviour. + * + * Used by the e-commerce GraphQL operations in `endpoints/platform.ts`. + * + * @see https://developers.activecampaign.com/reference/about-the-graphql-api + */ +export async function makeActiveCampaignGraphQLRequest( + query: string, + apiToken: string, + account: string, + variables?: Record, +): Promise { + assertCredentials(apiToken, account); + + const requestOptions: ApiRequestOptions = { + method: 'POST', + url: 'ecom/graphql', + body: variables ? { query, variables } : { query }, + mediaType: 'application/json; charset=utf-8', + }; + + return await request(buildConfig(apiToken, account), requestOptions, { + rateLimitConfig: ACTIVECAMPAIGN_RATE_LIMIT_CONFIG, + }); +} diff --git a/packages/activecampaign/endpoints.test.ts b/packages/activecampaign/endpoints.test.ts new file mode 100644 index 000000000..feb4e59fe --- /dev/null +++ b/packages/activecampaign/endpoints.test.ts @@ -0,0 +1,297 @@ +import { AuthMissingError } from 'corsair/core'; +import { auditPayload, listAuditPayload } from './endpoints/logging'; +import { + AC_PAGE_SIZE_MAX, + buildPaginationQuery, + compactBody, + compactQuery, +} from './endpoints/shared'; +import { + ActiveCampaignEndpointInputSchemas, + ActiveCampaignEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers, NON_IDEMPOTENT_OPERATIONS } from './error-handlers'; +import { activecampaignEndpointMeta } from './index'; + +const META = activecampaignEndpointMeta as Record< + string, + { riskLevel: string; description: string } +>; + +const OPERATION_COUNT = 304; +const READ_COUNT = 145; +const MUTATING_COUNT = 159; +const DESTRUCTIVE_COUNT = 46; + +/** 'fieldValues.setForContact' -> 'fieldValuesSetForContact' */ +function toOperationKey(path: string): string { + return path.replace(/\.(.)/g, (_match, c: string) => c.toUpperCase()); +} + +describe('endpoint registry', () => { + it('registers every operation exactly once', () => { + expect(Object.keys(META)).toHaveLength(OPERATION_COUNT); + }); + + /** + * The retry-safety check translates registry paths into operation keys, so + * that mapping has to hold for every path or the check silently skips the + * operations whose names do not line up. + */ + it('maps every registry path onto a declared schema key', () => { + const paths = Object.keys(META); + expect(paths).toHaveLength(OPERATION_COUNT); + for (const path of paths) { + expect(ActiveCampaignEndpointInputSchemas).toHaveProperty( + toOperationKey(path), + ); + } + }); + + /** + * Coverage sweep: an operation cannot enter the registry without both + * schemas, and a schema cannot exist without a registered operation. + */ + it('declares an input and output schema for every operation', () => { + const inputs = Object.keys(ActiveCampaignEndpointInputSchemas).sort(); + const outputs = Object.keys(ActiveCampaignEndpointOutputSchemas).sort(); + expect(inputs).toEqual(outputs); + expect(inputs).toHaveLength(OPERATION_COUNT); + }); + + it('gives every operation a meaningful description', () => { + const entries = Object.entries(META); + expect(entries).toHaveLength(OPERATION_COUNT); + for (const [, meta] of entries) { + expect(meta.description.length).toBeGreaterThan(10); + } + }); + + it('assigns every operation a known risk level', () => { + const levels = Object.values(META).map((m) => m.riskLevel); + expect(levels).toHaveLength(OPERATION_COUNT); + for (const level of levels) { + expect(['read', 'write', 'destructive']).toContain(level); + } + }); + + /** + * Non-vacuous: the match count is asserted before the loop, so the loop + * below cannot pass by matching nothing. + */ + it('marks every destructive operation destructive', () => { + const destructive = Object.entries(META).filter( + ([path]) => + path.toLowerCase().includes('delete') || + path.toLowerCase().includes('remove'), + ); + expect(destructive).toHaveLength(DESTRUCTIVE_COUNT); + for (const [, meta] of destructive) { + expect(meta.riskLevel).toBe('destructive'); + } + }); + + it('splits reads from state-changing operations', () => { + const byLevel = Object.values(META).reduce>( + (acc, m) => { + acc[m.riskLevel] = (acc[m.riskLevel] ?? 0) + 1; + return acc; + }, + {}, + ); + expect(byLevel.read).toBe(READ_COUNT); + expect((byLevel.write ?? 0) + (byLevel.destructive ?? 0)).toBe( + MUTATING_COUNT, + ); + expect(byLevel.destructive).toBe(DESTRUCTIVE_COUNT); + }); +}); + +describe('non-idempotent operation set', () => { + const mutatingKeys = Object.entries(META) + .filter(([, m]) => m.riskLevel !== 'read') + .map(([path]) => toOperationKey(path)) + .sort(); + + /** + * Corsair replays the whole endpoint call on retry and ActiveCampaign has + * no idempotency key, so the set must equal the state-changing operations + * exactly - a missing entry risks a duplicated write, and a stale entry is + * dead code that hides a real gap. + */ + it('equals the set of state-changing operations exactly', () => { + expect(mutatingKeys).toHaveLength(MUTATING_COUNT); + expect([...NON_IDEMPOTENT_OPERATIONS].sort()).toEqual(mutatingKeys); + }); + + it('contains no read operation', () => { + const readKeys = Object.entries(META) + .filter(([, m]) => m.riskLevel === 'read') + .map(([path]) => toOperationKey(path)); + expect(readKeys).toHaveLength(READ_COUNT); + for (const key of readKeys) { + expect(NON_IDEMPOTENT_OPERATIONS.has(key)).toBe(false); + } + }); +}); + +describe('error handlers', () => { + const writeCtx = { operation: 'tagsCreate' } as never; + const readCtx = { operation: 'tagsList' } as never; + + it('does not retry a write on a network error', async () => { + const result = await errorHandlers.NETWORK_ERROR.handler( + new Error('fetch failed'), + writeCtx, + ); + expect(result.maxRetries).toBe(0); + }); + + it('retries a read on a network error', async () => { + const result = await errorHandlers.NETWORK_ERROR.handler( + new Error('fetch failed'), + readCtx, + ); + expect(result.maxRetries).toBe(3); + }); + + /** A 429 rejected the request rather than applying it, so a write is safe. */ + it('retries a rate-limit error even for a write', async () => { + const result = await errorHandlers.RATE_LIMIT_ERROR.handler( + new Error('429 too many requests'), + ); + expect(result.maxRetries).toBe(5); + }); + + it('matches a 429 by message', () => { + expect( + errorHandlers.RATE_LIMIT_ERROR.match(new Error('Too Many Requests')), + ).toBe(true); + }); + + it('never retries a configuration fault', async () => { + const err = Object.assign(new Error('no account'), { + code: 'MISSING_ACCOUNT', + }); + expect(errorHandlers.CONFIGURATION_ERROR.match(err)).toBe(true); + const result = await errorHandlers.CONFIGURATION_ERROR.handler( + err, + writeCtx, + ); + expect(result.maxRetries).toBe(0); + }); + + /** + * Handler order is load-bearing: the first match wins, so a configuration + * fault must be reachable before the catch-all. + */ + it('orders the configuration handler before the default', () => { + const order = Object.keys(errorHandlers); + expect(order.indexOf('CONFIGURATION_ERROR')).toBeGreaterThanOrEqual(0); + expect(order.indexOf('CONFIGURATION_ERROR')).toBeLessThan( + order.indexOf('DEFAULT'), + ); + }); + + /** + * The shared account resolver raises AuthMissingError rather than an + * ActiveCampaignAPIError with a code, so without this branch a missing + * account slug would fall through to the catch-all handler and be reported + * as an unhandled error. + */ + it('does not treat a DNS failure as not-found', () => { + expect(errorHandlers.NOT_FOUND_ERROR.match(new Error('no such host'))).toBe( + false, + ); + expect( + errorHandlers.NETWORK_ERROR.match(new Error('getaddrinfo ENOTFOUND')), + ).toBe(true); + }); + + it('treats a missing credential as a configuration fault', async () => { + const err = new AuthMissingError('activecampaign', 'account'); + expect(errorHandlers.CONFIGURATION_ERROR.match(err)).toBe(true); + const result = await errorHandlers.CONFIGURATION_ERROR.handler( + err, + writeCtx, + ); + expect(result.maxRetries).toBe(0); + }); + + it('does not treat an ordinary error as a configuration fault', () => { + expect(errorHandlers.CONFIGURATION_ERROR.match(new Error('boom'))).toBe( + false, + ); + }); +}); + +describe('request body and query compaction', () => { + /** + * ActiveCampaign treats an absent field and an explicit null differently: + * omitting leaves a value alone, null clears it. + */ + it('strips undefined but keeps null', () => { + expect(compactBody({ a: 1, b: undefined, c: null })).toEqual({ + a: 1, + c: null, + }); + }); + + it('strips undefined query values', () => { + expect(compactQuery({ a: 'x', b: undefined })).toEqual({ a: 'x' }); + }); + + it('clamps the page size to the documented maximum', () => { + expect(buildPaginationQuery({ limit: 5000 }).limit).toBe(AC_PAGE_SIZE_MAX); + expect(buildPaginationQuery({ limit: 20 }).limit).toBe(20); + }); + + it('omits pagination entirely when unspecified', () => { + expect(buildPaginationQuery({})).toEqual({}); + }); +}); + +describe('audit payloads', () => { + it('records allowed identifiers by value', () => { + expect(auditPayload({ id: '42', limit: 10 }, ['id', 'limit'])).toEqual({ + id: '42', + limit: 10, + }); + }); + + /** + * The one place caller-supplied text could reach durable storage. + */ + it('records personal data as field names only', () => { + const payload = auditPayload( + { + id: '42', + email: 'someone@example.com', + firstName: 'Ada', + phone: '+15550100', + }, + ['id'], + ); + expect(payload).toEqual({ + id: '42', + fields: ['email', 'firstName', 'phone'], + }); + const serialised = JSON.stringify(payload); + expect(serialised).not.toContain('someone@example.com'); + expect(serialised).not.toContain('Ada'); + expect(serialised).not.toContain('+15550100'); + }); + + it('drops undefined keys rather than listing them', () => { + expect(auditPayload({ id: '1', email: undefined }, ['id'])).toEqual({ + id: '1', + }); + }); + + it('records a returned count for list operations', () => { + expect(listAuditPayload({ limit: 5 }, ['limit'], 3)).toEqual({ + limit: 5, + returnedCount: 3, + }); + }); +}); diff --git a/packages/activecampaign/endpoints/accounts.ts b/packages/activecampaign/endpoints/accounts.ts new file mode 100644 index 000000000..4d5bbf950 --- /dev/null +++ b/packages/activecampaign/endpoints/accounts.ts @@ -0,0 +1,417 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { + ActiveCampaignAccount, + ActiveCampaignAccountContact, + ActiveCampaignAccountCustomFieldMeta, + ActiveCampaignNote, +} from '../schema/database'; +import { persistRow } from './persist'; +import { makeResource } from './resource'; +import { AC_PAGE_SIZE_MAX, resolveAccount } from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +/** + * CRM accounts (organizations), their contact associations, their custom + * fields, and the account-wide notes resource. + * + * Account and note bodies carry free text a person wrote, so nothing here logs + * a body - only identifiers and counts reach the event log. + */ + +const accounts = makeResource({ + path: 'accounts', + one: 'account', + many: 'accounts', + event: 'activecampaign.accounts', + entity: ActiveCampaignAccount, + store: 'accounts', + label: 'account', + logKeys: ['id', 'limit', 'offset', 'owner'], + queryMap: { search: 'search' }, + bodyKeys: ['name', 'accountUrl', 'owner', 'fields'], +}); + +const accountContacts = makeResource({ + path: 'accountContacts', + one: 'accountContact', + many: 'accountContacts', + event: 'activecampaign.accountContacts', + entity: ActiveCampaignAccountContact, + store: 'accountContacts', + label: 'accountContact', + logKeys: ['id', 'limit', 'offset', 'contact', 'account'], + queryMap: { contact: 'filters[contact]', account: 'filters[account]' }, + bodyKeys: ['contact', 'account', 'jobTitle'], +}); + +const accountFieldMeta = makeResource({ + path: 'accountCustomFieldMeta', + one: 'accountCustomFieldMetum', + many: 'accountCustomFieldMeta', + event: 'activecampaign.accountCustomFieldMeta', + entity: ActiveCampaignAccountCustomFieldMeta, + store: 'accountCustomFieldMeta', + label: 'accountCustomFieldMeta', + bodyKeys: [ + 'fieldLabel', + 'fieldType', + 'fieldOptions', + 'fieldDefault', + 'fieldDefaultCurrency', + 'isFormVisible', + 'isRequired', + 'displayOrder', + ], +}); + +/** + * Field *values* on accounts. Not mirrored - a value is only meaningful + * alongside the account it belongs to, and the account itself is cached. + */ +const accountFieldData = makeResource({ + path: 'accountCustomFieldData', + one: 'accountCustomFieldDatum', + many: 'accountCustomFieldData', + event: 'activecampaign.accountCustomFieldData', + label: 'accountCustomFieldData', + logKeys: ['id', 'limit', 'offset', 'accountId', 'customFieldId'], + bodyKeys: ['accountId', 'customFieldId', 'fieldValue'], +}); + +const notes = makeResource({ + path: 'notes', + one: 'note', + many: 'notes', + event: 'activecampaign.notes', + entity: ActiveCampaignNote, + store: 'notes', + label: 'note', + // `note` is the body text a person wrote, so it is never logged by value. + logKeys: ['id', 'limit', 'offset', 'reltype', 'relid'], + bodyKeys: ['note', 'reltype', 'relid'], +}); + +// --- accounts -------------------------------------------------------------- +export const list = accounts.list as ActiveCampaignEndpoints['accountsList']; +export const get = accounts.get as ActiveCampaignEndpoints['accountsGet']; +export const create = + accounts.create as ActiveCampaignEndpoints['accountsCreate']; +export const update = + accounts.update as ActiveCampaignEndpoints['accountsUpdate']; +export const remove = + accounts.remove as ActiveCampaignEndpoints['accountsDelete']; + +// --- account contacts ------------------------------------------------------ +export const listContacts = + accountContacts.list as ActiveCampaignEndpoints['accountContactsList']; +export const getContact = + accountContacts.get as ActiveCampaignEndpoints['accountContactsGet']; +export const createContact = + accountContacts.create as ActiveCampaignEndpoints['accountContactsCreate']; +export const updateContact = + accountContacts.update as ActiveCampaignEndpoints['accountContactsUpdate']; +export const removeContact = + accountContacts.remove as ActiveCampaignEndpoints['accountContactsDelete']; + +// --- account custom fields ------------------------------------------------- +export const listFieldMeta = + accountFieldMeta.list as ActiveCampaignEndpoints['accountCustomFieldMetaList']; +export const getFieldMeta = + accountFieldMeta.get as ActiveCampaignEndpoints['accountCustomFieldMetaGet']; +export const createFieldMeta = + accountFieldMeta.create as ActiveCampaignEndpoints['accountCustomFieldMetaCreate']; +export const updateFieldMeta = + accountFieldMeta.update as ActiveCampaignEndpoints['accountCustomFieldMetaUpdate']; +export const removeFieldMeta = + accountFieldMeta.remove as ActiveCampaignEndpoints['accountCustomFieldMetaDelete']; + +export const listFieldData = + accountFieldData.list as ActiveCampaignEndpoints['accountCustomFieldDataList']; +export const getFieldData = + accountFieldData.get as ActiveCampaignEndpoints['accountCustomFieldDataGet']; +export const createFieldData = + accountFieldData.create as ActiveCampaignEndpoints['accountCustomFieldDataCreate']; +export const updateFieldData = + accountFieldData.update as ActiveCampaignEndpoints['accountCustomFieldDataUpdate']; +export const removeFieldData = + accountFieldData.remove as ActiveCampaignEndpoints['accountCustomFieldDataDelete']; + +// --- notes ----------------------------------------------------------------- +export const listNotes = notes.list as ActiveCampaignEndpoints['notesList']; +export const getNote = notes.get as ActiveCampaignEndpoints['notesGet']; +export const createNote = + notes.create as ActiveCampaignEndpoints['notesCreate']; +export const updateNote = + notes.update as ActiveCampaignEndpoints['notesUpdate']; +export const removeNote = + notes.remove as ActiveCampaignEndpoints['notesDelete']; + +// --------------------------------------------------------------------------- +// Operations outside the standard resource shape +// --------------------------------------------------------------------------- + +/** + * Creates an account, or updates the existing one with the same name. + * + * ActiveCampaign has no upsert route for accounts and enforces unique names, + * so this searches by name first and branches. The lookup is a read, so a + * transport failure between the two calls is safe to retry; the write half is + * listed as non-idempotent. + */ +export const upsert: ActiveCampaignEndpoints['accountsUpsert'] = async ( + ctx, + input, +) => { + const acct = await resolveAccount(ctx); + + // `search` is a substring match, so an exact name comparison decides. + // A match past the first page would otherwise POST and hit uniqueness. + let existing: { id?: string; name?: string } | undefined; + for (let offset = 0; ; ) { + const found = await makeActiveCampaignRequest<{ + accounts?: Array<{ id?: string; name?: string }>; + }>('accounts', ctx.key, acct, { + method: 'GET', + query: { search: input.name, limit: AC_PAGE_SIZE_MAX, offset }, + }); + existing = found.accounts?.find((a) => a.name === input.name); + if (existing) break; + const page = found.accounts?.length ?? 0; + if (page < AC_PAGE_SIZE_MAX) break; + offset += page; + } + + const body = { + account: { + name: input.name, + ...(input.accountUrl !== undefined && { accountUrl: input.accountUrl }), + ...(input.owner !== undefined && { owner: input.owner }), + ...(input.fields !== undefined && { fields: input.fields }), + }, + }; + + const response = existing?.id + ? await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['accountsUpsert'] + >(`accounts/${existing.id}`, ctx.key, acct, { method: 'PUT', body }) + : await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['accountsUpsert'] + >('accounts', ctx.key, acct, { method: 'POST', body }); + + await persistRow( + ctx.db.accounts, + ActiveCampaignAccount, + response.account, + 'account', + ); + + await logEventFromContext( + ctx, + 'activecampaign.accounts.upsert', + { + created: existing?.id === undefined, + owner: input.owner, + fields: ['name'], + }, + 'completed', + ); + return response; +}; + +/** + * Deletes many accounts in one request. + * + * Irreversible, and the whole batch is one call - a retry would re-issue every + * deletion, so it is listed as non-idempotent. + */ +export const removeBulk: ActiveCampaignEndpoints['accountsDeleteBulk'] = async ( + ctx, + input, +) => { + const acct = await resolveAccount(ctx); + await makeActiveCampaignRequest( + 'accounts/bulk_delete', + ctx.key, + acct, + { + method: 'POST', + body: { ids: input.ids }, + }, + ); + + // Evicting is best-effort per row; a mirror failure must not fail the call. + for (const id of input.ids) { + const store = ctx.db.accounts as + | { deleteByEntityId?: (entityId: string) => Promise } + | undefined; + if (store?.deleteByEntityId) { + try { + await store.deleteByEntityId(String(id)); + } catch (error) { + console.warn( + `[ACTIVECAMPAIGN] Failed to evict account ${id} from the cache:`, + error, + ); + } + } + } + + await logEventFromContext( + ctx, + 'activecampaign.accounts.deleteBulk', + { accountCount: input.ids.length, fields: ['ids'] }, + 'completed', + ); + return { ids: input.ids }; +}; + +/** + * Sets many account custom field values in one request. + * + * ActiveCampaign notes that when several items reference the same account, + * only the first updates that account's Last Modified date. + */ +export const createFieldDataBulk: ActiveCampaignEndpoints['accountCustomFieldDataCreateBulk'] = + async (ctx, input) => { + const acct = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['accountCustomFieldDataCreateBulk'] + >('accountCustomFieldData/bulkCreate', ctx.key, acct, { + method: 'POST', + body: input.items, + }); + + await logEventFromContext( + ctx, + 'activecampaign.accountCustomFieldData.createBulk', + { itemCount: input.items.length, fields: ['items'] }, + 'completed', + ); + return response; + }; + +export const updateFieldDataBulk: ActiveCampaignEndpoints['accountCustomFieldDataUpdateBulk'] = + async (ctx, input) => { + const acct = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['accountCustomFieldDataUpdateBulk'] + >('accountCustomFieldData/bulkUpdate', ctx.key, acct, { + method: 'PATCH', + body: input.items, + }); + + await logEventFromContext( + ctx, + 'activecampaign.accountCustomFieldData.updateBulk', + { itemCount: input.items.length, fields: ['items'] }, + 'completed', + ); + return response; + }; + +/** + * Adds a note to a contact, looked up by email. + * + * ActiveCampaign attaches contact notes with `reltype: 'Subscriber'`, so the + * email is resolved to an id first. The note body is caller-supplied text and + * is never logged. + */ +export const addContactNote: ActiveCampaignEndpoints['notesAddToContact'] = + async (ctx, input) => { + const acct = await resolveAccount(ctx); + + const found = await makeActiveCampaignRequest<{ + contacts?: Array<{ id?: string }>; + }>('contacts', ctx.key, acct, { + method: 'GET', + query: { email: input.email }, + }); + + const contactId = found.contacts?.[0]?.id; + if (!contactId) { + throw new Error( + `No ActiveCampaign contact matches the supplied email address`, + ); + } + + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['notesAddToContact'] + >('notes', ctx.key, acct, { + method: 'POST', + body: { + note: { note: input.note, reltype: 'Subscriber', relid: contactId }, + }, + }); + + await persistRow(ctx.db.notes, ActiveCampaignNote, response.note, 'note'); + + await logEventFromContext( + ctx, + 'activecampaign.notes.addToContact', + { relid: contactId, reltype: 'Subscriber', fields: ['email', 'note'] }, + 'completed', + ); + return response; + }; + +/** + * Notes attached to an account or a deal. + * + * ActiveCampaign has no per-entity note route - `/accounts/{id}/notes` and + * `/deals/{id}/notes` both answer 404 - so these post to the shared `/notes` + * collection with `reltype` fixed. They exist as named operations rather than + * leaving callers to set `reltype` themselves because the catalog lists them + * separately, and because an agent looking for "add a note to this deal" + * should find exactly that. + * + * The note body is text a person wrote and is never logged. + */ +function typedNote( + reltype: 'Account' | 'Deal', + event: string, +): ActiveCampaignEndpoints[K] { + return (async ( + ctx: Parameters[0], + input: { id: string; note: string }, + ) => { + const acct = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs[K] + >('notes', ctx.key, acct, { + method: 'POST', + body: { note: { note: input.note, reltype, relid: input.id } }, + }); + + await persistRow(ctx.db.notes, ActiveCampaignNote, response.note, 'note'); + + await logEventFromContext( + ctx, + event, + { relid: input.id, reltype, fields: ['note'] }, + 'completed', + ); + return response; + }) as ActiveCampaignEndpoints[K]; +} + +export const createAccountNote = typedNote<'notesCreateForAccount'>( + 'Account', + 'activecampaign.notes.createForAccount', +); +export const createDealNote = typedNote<'notesCreateForDeal'>( + 'Deal', + 'activecampaign.notes.createForDeal', +); + +/** + * Updating a note is the same call whichever entity it hangs off, because the + * note id already identifies it. These two exist so the operation surface + * matches the catalog; both delegate to the shared update rather than + * duplicating it. + */ +export const updateAccountNote = + notes.update as ActiveCampaignEndpoints['notesUpdateForAccount']; +export const updateDealNote = + notes.update as ActiveCampaignEndpoints['notesUpdateForDeal']; diff --git a/packages/activecampaign/endpoints/contacts.ts b/packages/activecampaign/endpoints/contacts.ts new file mode 100644 index 000000000..f580cdd00 --- /dev/null +++ b/packages/activecampaign/endpoints/contacts.ts @@ -0,0 +1,448 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { + ActiveCampaignContact, + ActiveCampaignContactList, + ActiveCampaignContactTag, +} from '../schema/database'; +import { auditPayload, listAuditPayload } from './logging'; +import { evictRow, persistRow, persistRows } from './persist'; +import { + buildPaginationQuery, + compactBody, + compactQuery, + resolveAccount, +} from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +export const list: ActiveCampaignEndpoints['contactsList'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactsList'] + >('contacts', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ + email: input.email, + search: input.search, + listid: input.listid, + tagid: input.tagid, + segmentid: input.segmentid, + status: input.status, + id_greater: input.id_greater, + 'orders[id]': input.orders_id, + }), + }, + }); + + await persistRows( + ctx.db.contacts, + ActiveCampaignContact, + response.contacts, + 'contact', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contacts.list', + listAuditPayload( + input, + ['limit', 'offset', 'listid', 'tagid', 'segmentid', 'status'], + response.contacts?.length ?? 0, + ), + 'completed', + ); + return response; +}; + +export const get: ActiveCampaignEndpoints['contactsGet'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactsGet'] + >(`contacts/${encodeURIComponent(input.id)}`, ctx.key, account, { + method: 'GET', + query: compactQuery({ automations: input.automations }), + }); + + await persistRow( + ctx.db.contacts, + ActiveCampaignContact, + response.contact, + 'contact', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contacts.get', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +/** + * Looks a contact up by email. ActiveCampaign has no dedicated find route - + * the collection accepts an `email` filter and returns a (possibly empty) + * array. + */ +export const find: ActiveCampaignEndpoints['contactsFind'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactsFind'] + >('contacts', ctx.key, account, { + method: 'GET', + query: { email: input.email }, + }); + + await persistRows( + ctx.db.contacts, + ActiveCampaignContact, + response.contacts, + 'contact', + ); + + // The email is the search term and is personal data; only the result count + // is recorded. + await logEventFromContext( + ctx, + 'activecampaign.contacts.find', + { matched: response.contacts?.length ?? 0, fields: ['email'] }, + 'completed', + ); + return response; +}; + +/** + * Creates a contact, or updates it if the email already exists. + * + * ActiveCampaign exposes this as `POST /contact/sync` - a singular path, + * unlike the plural `contacts` collection used everywhere else. + */ +export const createOrUpdate: ActiveCampaignEndpoints['contactsCreateOrUpdate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactsCreateOrUpdate'] + >('contact/sync', ctx.key, account, { + method: 'POST', + body: { + contact: compactBody({ + email: input.email, + firstName: input.firstName, + lastName: input.lastName, + phone: input.phone, + fieldValues: input.fieldValues, + }), + }, + }); + + await persistRow( + ctx.db.contacts, + ActiveCampaignContact, + response.contact, + 'contact', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contacts.createOrUpdate', + auditPayload(input, []), + 'completed', + ); + return response; + }; + +export const update: ActiveCampaignEndpoints['contactsUpdate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactsUpdate'] + >(`contacts/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { + contact: compactBody({ + email: input.email, + firstName: input.firstName, + lastName: input.lastName, + phone: input.phone, + fieldValues: input.fieldValues, + }), + }, + }); + + await persistRow( + ctx.db.contacts, + ActiveCampaignContact, + response.contact, + 'contact', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contacts.update', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +export const remove: ActiveCampaignEndpoints['contactsDelete'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `contacts/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await evictRow(ctx.db.contacts, input.id, 'contact'); + + await logEventFromContext( + ctx, + 'activecampaign.contacts.delete', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; +}; + +export const getLists: ActiveCampaignEndpoints['contactsGetLists'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactsGetLists'] + >(`contacts/${input.id}/contactLists`, ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await persistRows( + ctx.db.contactLists, + ActiveCampaignContactList, + response.contactLists, + 'contactList', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contacts.getLists', + listAuditPayload( + input, + ['id', 'limit', 'offset'], + response.contactLists?.length ?? 0, + ), + 'completed', + ); + return response; +}; + +export const getTags: ActiveCampaignEndpoints['contactsGetTags'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactsGetTags'] + >(`contacts/${input.id}/contactTags`, ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await persistRows( + ctx.db.contactTags, + ActiveCampaignContactTag, + response.contactTags, + 'contactTag', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contacts.getTags', + listAuditPayload( + input, + ['id', 'limit', 'offset'], + response.contactTags?.length ?? 0, + ), + 'completed', + ); + return response; +}; + +/** + * The remaining contact sub-resources return rows belonging to resource groups + * outside this PR's scope, so they are returned to the caller but not + * mirrored - caching a shape this plugin does not model would store rows that + * nothing can read back reliably. + */ +function subResource< + K extends + | 'contactsGetFieldValues' + | 'contactsGetAutomations' + | 'contactsGetGeoIps' + | 'contactsGetScoreValues' + | 'contactsGetDeals' + | 'contactsGetLogs' + | 'contactsGetTrackingLogs' + | 'contactsGetGoals' + | 'contactsGetAccountContacts' + | 'contactsGetNotes', +>(path: string, event: string): ActiveCampaignEndpoints[K] { + return (async ( + ctx: Parameters[0], + input: { id: string; limit?: number; offset?: number }, + ) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs[K] + >(`contacts/${encodeURIComponent(input.id)}/${path}`, ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + event, + auditPayload(input, ['id', 'limit', 'offset']), + 'completed', + ); + return response; + }) as ActiveCampaignEndpoints[K]; +} + +export const getFieldValues = subResource<'contactsGetFieldValues'>( + 'fieldValues', + 'activecampaign.contacts.getFieldValues', +); +export const getAutomations = subResource<'contactsGetAutomations'>( + 'contactAutomations', + 'activecampaign.contacts.getAutomations', +); +export const getGeoIps = subResource<'contactsGetGeoIps'>( + 'geoIps', + 'activecampaign.contacts.getGeoIps', +); +export const getScoreValues = subResource<'contactsGetScoreValues'>( + 'scoreValues', + 'activecampaign.contacts.getScoreValues', +); +export const getDeals = subResource<'contactsGetDeals'>( + 'deals', + 'activecampaign.contacts.getDeals', +); +export const getLogs = subResource<'contactsGetLogs'>( + 'contactLogs', + 'activecampaign.contacts.getLogs', +); +export const getTrackingLogs = subResource<'contactsGetTrackingLogs'>( + 'trackingLogs', + 'activecampaign.contacts.getTrackingLogs', +); +export const getGoals = subResource<'contactsGetGoals'>( + 'contactGoals', + 'activecampaign.contacts.getGoals', +); +export const getAccountContacts = subResource<'contactsGetAccountContacts'>( + 'accountContacts', + 'activecampaign.contacts.getAccountContacts', +); +export const getNotes = subResource<'contactsGetNotes'>( + 'notes', + 'activecampaign.contacts.getNotes', +); + +/** + * Singleton sub-resources: one record per contact rather than a collection, so + * they take no pagination and answer with a bare `{}` when the contact has no + * such record. + */ +function singletonSubResource< + K extends + | 'contactsGetData' + | 'contactsGetOrganization' + | 'contactsGetPlusAppend', +>(path: string, event: string): ActiveCampaignEndpoints[K] { + return (async ( + ctx: Parameters[0], + input: { id: string }, + ) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs[K] + >(`contacts/${encodeURIComponent(input.id)}/${path}`, ctx.key, account, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + event, + auditPayload(input, ['id']), + 'completed', + ); + return response; + }) as ActiveCampaignEndpoints[K]; +} + +export const getData = singletonSubResource<'contactsGetData'>( + 'contactData', + 'activecampaign.contacts.getData', +); +export const getOrganization = singletonSubResource<'contactsGetOrganization'>( + 'organization', + 'activecampaign.contacts.getOrganization', +); +export const getPlusAppend = singletonSubResource<'contactsGetPlusAppend'>( + 'plusAppend', + 'activecampaign.contacts.getPlusAppend', +); + +/** + * Account-wide activity feed, optionally narrowed to one contact. + * + * Activity rows are transactional - appended continuously and only meaningful + * against a time range - so they are returned but never mirrored. + */ +export const listActivities: ActiveCampaignEndpoints['activitiesList'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['activitiesList'] + >('activities', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ contact: input.contact, after: input.after }), + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.activities.list', + listAuditPayload( + input, + ['contact', 'limit', 'offset', 'after'], + response.activities?.length ?? 0, + ), + 'completed', + ); + return response; +}; diff --git a/packages/activecampaign/endpoints/content.ts b/packages/activecampaign/endpoints/content.ts new file mode 100644 index 000000000..25ce82151 --- /dev/null +++ b/packages/activecampaign/endpoints/content.ts @@ -0,0 +1,684 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { + ActiveCampaignAutomation, + ActiveCampaignCampaign, + ActiveCampaignForm, + ActiveCampaignMessage, + ActiveCampaignPersonalization, + ActiveCampaignSavedResponse, + ActiveCampaignSegment, + ActiveCampaignTemplate, +} from '../schema/database'; +import { auditPayload, listAuditPayload } from './logging'; +import { makeResource } from './resource'; +import { + AC_PAGE_SIZE_MAX, + buildPaginationQuery, + compactQuery, + resolveAccount, +} from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +/** + * Campaigns, messaging, forms, personalization variables, automations and + * segments. + * + * Message bodies and personalization content are author-written text, so no + * operation here logs a body - only identifiers, counts and status flags. + */ + +const campaigns = makeResource({ + path: 'campaigns', + one: 'campaign', + many: 'campaigns', + event: 'activecampaign.campaigns', + entity: ActiveCampaignCampaign, + store: 'campaigns', + label: 'campaign', + logKeys: ['id', 'limit', 'offset', 'type', 'status'], + queryMap: { type: 'filters[type]', status: 'filters[status]' }, + bodyKeys: ['type', 'name', 'status', 'sdate', 'segmentid', 'p', 'm'], +}); + +const messages = makeResource({ + path: 'messages', + one: 'message', + many: 'messages', + event: 'activecampaign.messages', + entity: ActiveCampaignMessage, + store: 'messages', + label: 'message', + // subject, html and text are author content and are never logged by value. + logKeys: ['id', 'limit', 'offset', 'format', 'user'], + bodyKeys: [ + 'subject', + 'fromname', + 'fromemail', + 'reply2', + 'html', + 'text', + 'name', + 'format', + 'user', + 'preheader_text', + ], +}); + +const savedResponses = makeResource({ + path: 'savedResponses', + one: 'savedResponse', + many: 'savedResponses', + event: 'activecampaign.savedResponses', + entity: ActiveCampaignSavedResponse, + store: 'savedResponses', + label: 'savedResponse', + logKeys: ['id', 'limit', 'offset'], + bodyKeys: ['title', 'subject', 'body', 'userid'], +}); + +const forms = makeResource({ + path: 'forms', + one: 'form', + many: 'forms', + event: 'activecampaign.forms', + entity: ActiveCampaignForm, + store: 'forms', + label: 'form', + bodyKeys: ['name', 'action', 'layout', 'style'], +}); + +const personalizations = makeResource({ + path: 'personalizations', + one: 'personalization', + many: 'personalizations', + event: 'activecampaign.personalizations', + entity: ActiveCampaignPersonalization, + store: 'personalizations', + label: 'personalization', + // `content` is author-written text. + logKeys: ['id', 'limit', 'offset', 'tag', 'format'], + bodyKeys: ['name', 'tag', 'content', 'format', 'lists'], +}); + +const templates = makeResource({ + path: 'templates', + one: 'template', + many: 'templates', + event: 'activecampaign.templates', + entity: ActiveCampaignTemplate, + store: 'templates', + label: 'template', +}); + +const automations = makeResource({ + path: 'automations', + one: 'automation', + many: 'automations', + event: 'activecampaign.automations', + entity: ActiveCampaignAutomation, + store: 'automations', + label: 'automation', +}); + +const segments = makeResource({ + path: 'segments', + one: 'segment', + many: 'segments', + event: 'activecampaign.segments', + entity: ActiveCampaignSegment, + store: 'segments', + label: 'segment', + bodyKeys: ['name', 'logic'], +}); + +// --- campaigns ------------------------------------------------------------- +export const listCampaigns = + campaigns.list as ActiveCampaignEndpoints['campaignsList']; +export const getCampaign = + campaigns.get as ActiveCampaignEndpoints['campaignsGet']; +export const createCampaign = + campaigns.create as ActiveCampaignEndpoints['campaignsCreate']; +export const updateCampaign = + campaigns.update as ActiveCampaignEndpoints['campaignsUpdate']; + +// --- messages -------------------------------------------------------------- +export const listMessages = + messages.list as ActiveCampaignEndpoints['messagesList']; +export const getMessage = + messages.get as ActiveCampaignEndpoints['messagesGet']; +export const createMessage = + messages.create as ActiveCampaignEndpoints['messagesCreate']; +export const updateMessage = + messages.update as ActiveCampaignEndpoints['messagesUpdate']; +export const removeMessage = + messages.remove as ActiveCampaignEndpoints['messagesDelete']; + +// --- saved responses ------------------------------------------------------- +export const listSavedResponses = + savedResponses.list as ActiveCampaignEndpoints['savedResponsesList']; +export const getSavedResponse = + savedResponses.get as ActiveCampaignEndpoints['savedResponsesGet']; +export const createSavedResponse = + savedResponses.create as ActiveCampaignEndpoints['savedResponsesCreate']; +export const updateSavedResponse = + savedResponses.update as ActiveCampaignEndpoints['savedResponsesUpdate']; +export const removeSavedResponse = + savedResponses.remove as ActiveCampaignEndpoints['savedResponsesDelete']; + +// --- forms ----------------------------------------------------------------- +export const listForms = forms.list as ActiveCampaignEndpoints['formsList']; +export const getForm = forms.get as ActiveCampaignEndpoints['formsGet']; +export const removeForm = + forms.remove as ActiveCampaignEndpoints['formsDelete']; + +// --- personalization variables --------------------------------------------- +export const listVariables = + personalizations.list as ActiveCampaignEndpoints['personalizationsList']; +export const getVariable = + personalizations.get as ActiveCampaignEndpoints['personalizationsGet']; +export const updateVariable = + personalizations.update as ActiveCampaignEndpoints['personalizationsUpdate']; +export const removeVariable = + personalizations.remove as ActiveCampaignEndpoints['personalizationsDelete']; + +// --- templates ------------------------------------------------------------- +export const getTemplate = + templates.get as ActiveCampaignEndpoints['templatesGet']; + +// --- automations ----------------------------------------------------------- +export const listAutomations = + automations.list as ActiveCampaignEndpoints['automationsList']; + +// --- segments -------------------------------------------------------------- +export const listSegments = + segments.list as ActiveCampaignEndpoints['segmentsList']; +export const getSegment = + segments.get as ActiveCampaignEndpoints['segmentsGet']; +export const createSegment = + segments.create as ActiveCampaignEndpoints['segmentsCreate']; +export const updateSegment = + segments.update as ActiveCampaignEndpoints['segmentsUpdate']; +export const removeSegment = + segments.remove as ActiveCampaignEndpoints['segmentsDelete']; + +// --------------------------------------------------------------------------- +// Campaign sub-resources +// +// Each returns rows belonging to a resource this plugin does not model, so +// they are returned to the caller but never mirrored. +// --------------------------------------------------------------------------- + +function campaignSub< + K extends + | 'campaignsGetLinks' + | 'campaignsGetMessages' + | 'campaignsGetAutomations' + | 'campaignsGetAutomationLists' + | 'campaignsGetUser', +>(path: string, event: string): ActiveCampaignEndpoints[K] { + return (async ( + ctx: Parameters[0], + input: { id: string; limit?: number; offset?: number }, + ) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs[K] + >(`campaigns/${input.id}/${path}`, ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + event, + auditPayload(input, ['id', 'limit', 'offset']), + 'completed', + ); + return response; + }) as ActiveCampaignEndpoints[K]; +} + +export const getCampaignLinks = campaignSub<'campaignsGetLinks'>( + 'links', + 'activecampaign.campaigns.getLinks', +); +export const getCampaignMessages = campaignSub<'campaignsGetMessages'>( + 'campaignMessages', + 'activecampaign.campaigns.getMessages', +); +export const getCampaignAutomations = campaignSub<'campaignsGetAutomations'>( + 'automations', + 'activecampaign.campaigns.getAutomations', +); +export const getCampaignAutomationLists = + campaignSub<'campaignsGetAutomationLists'>( + 'campaignLists', + 'activecampaign.campaigns.getAutomationLists', + ); +export const getCampaignUser = campaignSub<'campaignsGetUser'>( + 'user', + 'activecampaign.campaigns.getUser', +); + +/** + * Duplicates an existing campaign, content and configuration included. + */ +export const duplicateCampaign: ActiveCampaignEndpoints['campaignsDuplicate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['campaignsDuplicate'] + >(`campaigns/${input.id}/duplicate`, ctx.key, account, { method: 'POST' }); + + await logEventFromContext( + ctx, + 'activecampaign.campaigns.duplicate', + auditPayload(input, ['id']), + 'completed', + ); + return response; + }; + +/** + * Creates a shareable link for a campaign template. + */ +export const createTemplateShareLink: ActiveCampaignEndpoints['templatesCreateShareLink'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['templatesCreateShareLink'] + >(`templates/${input.id}/generateShareLink`, ctx.key, account, { + method: 'POST', + }); + + await logEventFromContext( + ctx, + 'activecampaign.templates.createShareLink', + auditPayload(input, ['id']), + 'completed', + ); + return response; + }; + +/** + * Submits a form opt-in on a contact's behalf. + * + * This is a consent action: it records that the contact opted in through the + * given form, and can trigger the form's automations. The email is the + * subject's own data, so only the form id and the result are logged. + */ +export const createFormOptin: ActiveCampaignEndpoints['formsCreateOptin'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['formsCreateOptin'] + >('forms/optin', ctx.key, account, { + method: 'POST', + body: { + optin: { + formid: input.formid, + email: input.email, + ...(input.firstName !== undefined && { firstName: input.firstName }), + ...(input.lastName !== undefined && { lastName: input.lastName }), + }, + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.forms.createOptin', + { formid: input.formid, fields: ['email', 'firstName', 'lastName'] }, + 'completed', + ); + return response; + }; + +/** + * Creates a personalization variable. + * + * Uses the singular `personalization` path, unlike the plural collection the + * other verbs use. + */ +export const createVariable: ActiveCampaignEndpoints['personalizationsCreate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['personalizationsCreate'] + >('personalizations', ctx.key, account, { + method: 'POST', + body: { + personalization: { + name: input.name, + tag: input.tag, + content: input.content, + ...(input.format !== undefined && { format: input.format }), + ...(input.lists !== undefined && { lists: input.lists }), + }, + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.personalizations.create', + auditPayload(input, ['tag', 'format']), + 'completed', + ); + return response; + }; + +/** + * Deletes many personalization variables at once. Ids that do not exist are + * ignored by ActiveCampaign rather than raising. + */ +export const removeVariablesBulk: ActiveCampaignEndpoints['personalizationsDeleteBulk'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + 'personalizations/bulkDelete', + ctx.key, + account, + { method: 'POST', body: { ids: input.ids } }, + ); + + const store = ctx.db.personalizations as + | { deleteByEntityId?: (entityId: string) => Promise } + | undefined; + for (const id of input.ids) { + if (store?.deleteByEntityId) { + try { + await store.deleteByEntityId(String(id)); + } catch (error) { + console.warn( + `[ACTIVECAMPAIGN] Failed to evict personalization ${id} from the cache:`, + error, + ); + } + } + } + + await logEventFromContext( + ctx, + 'activecampaign.personalizations.deleteBulk', + { variableCount: input.ids.length, fields: ['ids'] }, + 'completed', + ); + return { ids: input.ids }; + }; + +/** + * Locks or unlocks a personalization variable. Locking prevents edits. + */ +function setVariableLock< + K extends 'personalizationsLock' | 'personalizationsUnlock', +>(locked: boolean, event: string): ActiveCampaignEndpoints[K] { + return (async ( + ctx: Parameters[0], + input: { id: string }, + ) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs[K] + >(`personalizations/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { personalization: { locked: locked ? 1 : 0 } }, + }); + + await logEventFromContext( + ctx, + event, + auditPayload(input, ['id']), + 'completed', + ); + return response; + }) as ActiveCampaignEndpoints[K]; +} + +export const lockVariable = setVariableLock<'personalizationsLock'>( + true, + 'activecampaign.personalizations.lock', +); +export const unlockVariable = setVariableLock<'personalizationsUnlock'>( + false, + 'activecampaign.personalizations.unlock', +); + +// --------------------------------------------------------------------------- +// Automation enrolment +// --------------------------------------------------------------------------- + +/** + * Contact-to-automation enrolments. Transactional - a contact can enter the + * same automation many times - so these are never mirrored. + */ +export const listContactAutomations: ActiveCampaignEndpoints['contactAutomationsList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactAutomationsList'] + >('contactAutomations', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + 'activecampaign.contactAutomations.list', + listAuditPayload( + input, + ['limit', 'offset'], + response.contactAutomations?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +export const getContactAutomation: ActiveCampaignEndpoints['contactAutomationsGet'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactAutomationsGet'] + >(`contactAutomations/${input.id}`, ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.contactAutomations.get', + auditPayload(input, ['id']), + 'completed', + ); + return response; + }; + +/** + * How many times a contact has entered each automation. + */ +export const getAutomationEntryCounts: ActiveCampaignEndpoints['contactAutomationsEntryCounts'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactAutomationsEntryCounts'] + >(`contacts/${input.id}/automationEntryCounts`, ctx.key, account, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'activecampaign.contactAutomations.entryCounts', + auditPayload(input, ['id']), + 'completed', + ); + return response; + }; + +/** + * Enrols a contact in an automation, looked up by email. + * + * Automations cannot be created through the API - only through the UI - so the + * automation must already exist. The contact is resolved to an id first, + * because the enrolment endpoint takes ids rather than emails. + */ +export const addContactToAutomation: ActiveCampaignEndpoints['contactAutomationsAdd'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + + const found = await makeActiveCampaignRequest<{ + contacts?: Array<{ id?: string }>; + }>('contacts', ctx.key, account, { + method: 'GET', + query: { email: input.email }, + }); + + const contactId = found.contacts?.[0]?.id; + if (!contactId) { + throw new Error( + 'No ActiveCampaign contact matches the supplied email address', + ); + } + + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactAutomationsAdd'] + >('contactAutomations', ctx.key, account, { + method: 'POST', + body: { + contactAutomation: { + contact: contactId, + automation: input.automation_id, + }, + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.contactAutomations.add', + { + contact: contactId, + automation: input.automation_id, + fields: ['email'], + }, + 'completed', + ); + return response; + }; + +/** + * Removes a contact from an automation. + * + * A contact can hold several enrolments in the same automation, so this + * resolves the contact, lists their enrolments, and removes either every + * matching run or only the most recent. Destructive and not reversible. + */ +export const removeContactFromAutomation: ActiveCampaignEndpoints['contactAutomationsRemove'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + + const found = await makeActiveCampaignRequest<{ + contacts?: Array<{ id?: string }>; + }>('contacts', ctx.key, account, { + method: 'GET', + query: { email: input.email }, + }); + + const contactId = found.contacts?.[0]?.id; + if (!contactId) { + throw new Error( + 'No ActiveCampaign contact matches the supplied email address', + ); + } + + type Enrolment = { id?: string; automation?: string; adddate?: string }; + const enrolments: Enrolment[] = []; + for (let offset = 0; ; ) { + const page = await makeActiveCampaignRequest<{ + contactAutomations?: Enrolment[]; + meta?: { total?: string | number }; + }>(`contacts/${contactId}/contactAutomations`, ctx.key, account, { + method: 'GET', + query: { limit: AC_PAGE_SIZE_MAX, offset }, + }); + const rows = page.contactAutomations ?? []; + enrolments.push(...rows); + offset += rows.length; + const total = Number(page.meta?.total); + if (rows.length === 0 || rows.length < AC_PAGE_SIZE_MAX) break; + if (Number.isFinite(total) && offset >= total) break; + } + + const matching = enrolments.filter( + (e) => e.automation === input.automation_id && e.id, + ); + matching.sort((a, b) => + String(a.adddate ?? '').localeCompare(String(b.adddate ?? '')), + ); + const targets = + input.run_remove_option === 'last' ? matching.slice(-1) : matching; + + let removed = 0; + try { + for (const target of targets) { + await makeActiveCampaignRequest( + `contactAutomations/${target.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + removed++; + } + } catch (error) { + await logEventFromContext( + ctx, + 'activecampaign.contactAutomations.remove', + { + contact: contactId, + automation: input.automation_id, + removed, + fields: ['email'], + }, + 'failed', + ); + throw error; + } + + await logEventFromContext( + ctx, + 'activecampaign.contactAutomations.remove', + { + contact: contactId, + automation: input.automation_id, + removed, + fields: ['email'], + }, + 'completed', + ); + return { removed }; + }; + +/** + * Saved segments, exposed by ActiveCampaign as "audiences". + */ +export const listAudiences: ActiveCampaignEndpoints['segmentsListAudiences'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsListAudiences'] + >('segments', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ 'filters[name]': input.name }), + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.segments.listAudiences', + listAuditPayload( + input, + ['limit', 'offset'], + response.segments?.length ?? 0, + ), + 'completed', + ); + return response; + }; diff --git a/packages/activecampaign/endpoints/deals.ts b/packages/activecampaign/endpoints/deals.ts new file mode 100644 index 000000000..0a6251bc8 --- /dev/null +++ b/packages/activecampaign/endpoints/deals.ts @@ -0,0 +1,573 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { + ActiveCampaignDeal, + ActiveCampaignDealCustomFieldMeta, + ActiveCampaignDealGroup, + ActiveCampaignDealRole, + ActiveCampaignDealStage, + ActiveCampaignDealTask, + ActiveCampaignDealTaskType, + ActiveCampaignTaskOutcome, +} from '../schema/database'; +import { auditPayload, listAuditPayload } from './logging'; +import { persistRow } from './persist'; +import { makeResource } from './resource'; +import { + AC_PAGE_SIZE_MAX, + buildPaginationQuery, + compactQuery, + resolveAccount, +} from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +/** + * The CRM resources. Everything here follows ActiveCampaign's standard REST + * shape, so the handlers come from `makeResource` and only the strings differ. + * + * Two asymmetries are deliberate, and both match the OSS catalog rather than + * the API: there is no create-deal operation (the catalog lists retrieve, + * update and delete but no create), and deal activities are read-only. + */ + +const deals = makeResource({ + path: 'deals', + one: 'deal', + many: 'deals', + event: 'activecampaign.deals', + entity: ActiveCampaignDeal, + store: 'deals', + label: 'deal', + logKeys: ['id', 'limit', 'offset', 'status', 'stage', 'group', 'owner'], + queryMap: { + search: 'filters[search]', + search_field: 'filters[search_field]', + title: 'filters[title]', + stage: 'filters[stage]', + group: 'filters[group]', + status: 'filters[status]', + owner: 'filters[owner]', + nextdate_range: 'filters[nextdate_range]', + tag: 'filters[tag]', + tasktype: 'filters[tasktype]', + created_before: 'filters[created_before]', + created_after: 'filters[created_after]', + updated_before: 'filters[updated_before]', + updated_after: 'filters[updated_after]', + organization: 'filters[organization]', + minimum_value: 'filters[minimum_value]', + maximum_value: 'filters[maximum_value]', + score_greater_than: 'filters[score_greater_than]', + score_less_than: 'filters[score_less_than]', + score: 'filters[score]', + order_id: 'orders[id]', + order_title: 'orders[title]', + order_value: 'orders[value]', + order_created: 'orders[cdate]', + order_updated: 'orders[mdate]', + order_contact_name: 'orders[contact_name]', + order_contact_orgname: 'orders[contact_orgname]', + order_next_action: 'orders[next-action]', + }, + bodyKeys: [ + 'title', + 'description', + 'value', + 'currency', + 'group', + 'stage', + 'owner', + 'contact', + 'organization', + 'status', + 'percent', + 'fields', + ], +}); + +const dealGroups = makeResource({ + path: 'dealGroups', + one: 'dealGroup', + many: 'dealGroups', + event: 'activecampaign.dealGroups', + entity: ActiveCampaignDealGroup, + store: 'dealGroups', + label: 'dealGroup', + queryMap: { title: 'filters[title]' }, + bodyKeys: [ + 'title', + 'currency', + 'allgroups', + 'autoassign', + 'allusers', + 'users', + 'groups', + ], +}); + +const dealStages = makeResource({ + path: 'dealStages', + one: 'dealStage', + many: 'dealStages', + event: 'activecampaign.dealStages', + entity: ActiveCampaignDealStage, + store: 'dealStages', + label: 'dealStage', + queryMap: { + title: 'filters[title]', + group: 'filters[d_groupid]', + order_title: 'orders[title]', + }, + bodyKeys: ['title', 'group', 'order', 'width', 'color', 'cardRegion1'], +}); + +const dealTasks = makeResource({ + path: 'dealTasks', + one: 'dealTask', + many: 'dealTasks', + event: 'activecampaign.dealTasks', + entity: ActiveCampaignDealTask, + store: 'dealTasks', + label: 'dealTask', + logKeys: ['id', 'limit', 'offset', 'relid', 'reltype', 'dealTasktype'], + queryMap: { + title: 'filters[title]', + reltype: 'filters[reltype]', + relid: 'filters[relid]', + status: 'filters[status]', + note: 'filters[note]', + duedate: 'filters[duedate]', + dealTasktype: 'filters[d_tasktypeid]', + userid: 'filters[userid]', + due_after: 'filters[due_after]', + due_before: 'filters[due_before]', + duedate_range: 'filters[duedate_range]', + assignee_userid: 'filters[assignee_userid]', + outcome_id: 'filters[outcome_id]', + }, + bodyKeys: [ + 'title', + 'relid', + 'reltype', + 'dealTasktype', + 'ownerType', + 'status', + 'note', + 'duedate', + 'edate', + 'assignee', + 'triggerAutomationOnCreate', + 'doneAutomation', + 'outcomeId', + 'outcomeInfo', + ], +}); + +const dealTaskTypes = makeResource({ + // ActiveCampaign spells this path with a lowercase "t" in "Tasktype", + // unlike dealTasks. Confirmed against the live API on 2026-08-13. + path: 'dealTasktypes', + one: 'dealTasktype', + many: 'dealTasktypes', + event: 'activecampaign.dealTaskTypes', + entity: ActiveCampaignDealTaskType, + store: 'dealTaskTypes', + label: 'dealTaskType', + bodyKeys: ['title', 'defduration', 'status', 'display_order', 'outcomes'], +}); + +const dealRoles = makeResource({ + path: 'dealRoles', + one: 'dealRole', + many: 'dealRoles', + event: 'activecampaign.dealRoles', + entity: ActiveCampaignDealRole, + store: 'dealRoles', + label: 'dealRole', + bodyKeys: ['title'], +}); + +const taskOutcomes = makeResource({ + path: 'taskOutcomes', + one: 'taskOutcome', + many: 'taskOutcomes', + event: 'activecampaign.taskOutcomes', + entity: ActiveCampaignTaskOutcome, + store: 'taskOutcomes', + label: 'taskOutcome', + bodyKeys: ['title', 'sentiment', 'status', 'dealTasktypes'], +}); + +const dealCustomFieldMeta = makeResource({ + path: 'dealCustomFieldMeta', + one: 'dealCustomFieldMetum', + many: 'dealCustomFieldMeta', + event: 'activecampaign.dealCustomFieldMeta', + entity: ActiveCampaignDealCustomFieldMeta, + store: 'dealCustomFieldMeta', + label: 'dealCustomFieldMeta', + bodyKeys: [ + 'fieldLabel', + 'fieldType', + 'fieldOptions', + 'fieldDefault', + 'fieldDefaultCurrency', + 'isFormVisible', + 'isRequired', + 'displayOrder', + ], +}); + +/** + * Field *values* on deals. Not mirrored: a value is only meaningful alongside + * the deal it belongs to, and the deal itself is already cached. + */ +const dealCustomFieldData = makeResource({ + path: 'dealCustomFieldData', + one: 'dealCustomFieldDatum', + many: 'dealCustomFieldData', + event: 'activecampaign.dealCustomFieldData', + label: 'dealCustomFieldData', + logKeys: ['id', 'limit', 'offset', 'dealId', 'customFieldId'], + queryMap: { dealId: 'filters[dealId]' }, + bodyKeys: ['dealId', 'customFieldId', 'fieldValue', 'fieldCurrency'], +}); + +/** + * Secondary contacts on a deal - the contact-to-deal association, which is + * distinct from the deal's primary contact. + */ +const contactDeals = makeResource({ + path: 'contactDeals', + one: 'contactDeal', + many: 'contactDeals', + event: 'activecampaign.contactDeals', + label: 'contactDeal', + logKeys: ['id', 'limit', 'offset', 'contact', 'deal', 'role'], + bodyKeys: ['contact', 'deal', 'role', 'jobTitle'], +}); + +// --- deals ----------------------------------------------------------------- +export const list = deals.list as ActiveCampaignEndpoints['dealsList']; +export const listFiltered = + deals.list as ActiveCampaignEndpoints['dealsListFiltered']; +export const get = deals.get as ActiveCampaignEndpoints['dealsGet']; +export const update = deals.update as ActiveCampaignEndpoints['dealsUpdate']; +export const remove = deals.remove as ActiveCampaignEndpoints['dealsDelete']; + +// --- pipelines and stages -------------------------------------------------- +export const listGroups = + dealGroups.list as ActiveCampaignEndpoints['dealGroupsList']; +export const getGroup = + dealGroups.get as ActiveCampaignEndpoints['dealGroupsGet']; +export const createGroup = + dealGroups.create as ActiveCampaignEndpoints['dealGroupsCreate']; +export const updateGroup = + dealGroups.update as ActiveCampaignEndpoints['dealGroupsUpdate']; +export const removeGroup = + dealGroups.remove as ActiveCampaignEndpoints['dealGroupsDelete']; + +export const listStages = + dealStages.list as ActiveCampaignEndpoints['dealStagesList']; +export const getStage = + dealStages.get as ActiveCampaignEndpoints['dealStagesGet']; +export const createStage = + dealStages.create as ActiveCampaignEndpoints['dealStagesCreate']; +export const updateStage = + dealStages.update as ActiveCampaignEndpoints['dealStagesUpdate']; +export const removeStage = + dealStages.remove as ActiveCampaignEndpoints['dealStagesDelete']; + +// --- tasks ----------------------------------------------------------------- +export const listTasks = + dealTasks.list as ActiveCampaignEndpoints['dealTasksList']; +export const getTask = dealTasks.get as ActiveCampaignEndpoints['dealTasksGet']; +export const createTask = + dealTasks.create as ActiveCampaignEndpoints['dealTasksCreate']; +export const updateTask = + dealTasks.update as ActiveCampaignEndpoints['dealTasksUpdate']; +export const removeTask = + dealTasks.remove as ActiveCampaignEndpoints['dealTasksDelete']; + +export const listTaskTypes = + dealTaskTypes.list as ActiveCampaignEndpoints['dealTaskTypesList']; +export const getTaskType = + dealTaskTypes.get as ActiveCampaignEndpoints['dealTaskTypesGet']; +export const createTaskType = + dealTaskTypes.create as ActiveCampaignEndpoints['dealTaskTypesCreate']; +export const updateTaskType = + dealTaskTypes.update as ActiveCampaignEndpoints['dealTaskTypesUpdate']; + +export const listOutcomes = + taskOutcomes.list as ActiveCampaignEndpoints['taskOutcomesList']; +export const getOutcome = + taskOutcomes.get as ActiveCampaignEndpoints['taskOutcomesGet']; +export const createOutcome = + taskOutcomes.create as ActiveCampaignEndpoints['taskOutcomesCreate']; + +// --- roles and secondary contacts ------------------------------------------ +export const listRoles = + dealRoles.list as ActiveCampaignEndpoints['dealRolesList']; +export const createRole = + dealRoles.create as ActiveCampaignEndpoints['dealRolesCreate']; +export const removeRole = + dealRoles.remove as ActiveCampaignEndpoints['dealRolesDelete']; + +export const listSecondaryContacts = + contactDeals.list as ActiveCampaignEndpoints['contactDealsList']; +export const getSecondaryContact = + contactDeals.get as ActiveCampaignEndpoints['contactDealsGet']; +export const addSecondaryContact = + contactDeals.create as ActiveCampaignEndpoints['contactDealsCreate']; +export const updateSecondaryContact = + contactDeals.update as ActiveCampaignEndpoints['contactDealsUpdate']; +export const removeSecondaryContact = + contactDeals.remove as ActiveCampaignEndpoints['contactDealsDelete']; + +// --- deal custom fields ---------------------------------------------------- +export const listFieldMeta = + dealCustomFieldMeta.list as ActiveCampaignEndpoints['dealCustomFieldMetaList']; +export const getFieldMeta = + dealCustomFieldMeta.get as ActiveCampaignEndpoints['dealCustomFieldMetaGet']; +export const createFieldMeta = + dealCustomFieldMeta.create as ActiveCampaignEndpoints['dealCustomFieldMetaCreate']; +export const updateFieldMeta = + dealCustomFieldMeta.update as ActiveCampaignEndpoints['dealCustomFieldMetaUpdate']; +export const removeFieldMeta = + dealCustomFieldMeta.remove as ActiveCampaignEndpoints['dealCustomFieldMetaDelete']; + +export const listFieldData = + dealCustomFieldData.list as ActiveCampaignEndpoints['dealCustomFieldDataList']; +export const getFieldData = + dealCustomFieldData.get as ActiveCampaignEndpoints['dealCustomFieldDataGet']; +export const updateFieldData = + dealCustomFieldData.update as ActiveCampaignEndpoints['dealCustomFieldDataUpdate']; +export const removeFieldData = + dealCustomFieldData.remove as ActiveCampaignEndpoints['dealCustomFieldDataDelete']; + +// --------------------------------------------------------------------------- +// Operations that do not fit the standard resource shape +// --------------------------------------------------------------------------- + +/** + * Deal activity feed. Read-only and never mirrored - activities are appended + * continuously and are only meaningful against a time range. + */ +export const listActivities: ActiveCampaignEndpoints['dealActivitiesList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['dealActivitiesList'] + >('dealActivities', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ + deal: input.deal, + exclude: input.exclude, + 'filters[data_type]': input.data_type, + 'filters[data_id]': input.data_id, + }), + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.dealActivities.list', + listAuditPayload( + input, + ['deal', 'exclude', 'data_type', 'data_id', 'limit', 'offset'], + response.dealActivities?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +/** + * Reassigns many deals to new owners in one call. + * + * Requires deal-management, pipeline and reassign permissions. The whole batch + * is one request, so a retry would re-apply every reassignment - it is listed + * as non-idempotent for that reason. + */ +export const updateOwnersBulk: ActiveCampaignEndpoints['dealsUpdateOwnersBulk'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['dealsUpdateOwnersBulk'] + >('deals/bulkUpdate', ctx.key, account, { + method: 'PATCH', + body: { deals: input.deals }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.deals.updateOwnersBulk', + { dealCount: input.deals.length, fields: ['deals'] }, + 'completed', + ); + return response; + }; + +/** + * Moves every deal in one stage to another stage. + * + * Both stages must belong to the same pipeline; ActiveCampaign answers 422 + * otherwise, which the validation handler surfaces without a retry. + */ +export const moveStageDeals: ActiveCampaignEndpoints['dealStagesMoveDeals'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['dealStagesMoveDeals'] + >(`dealStages/${input.id}/deals`, ctx.key, account, { + method: 'PUT', + body: { deals: { stage: input.stage } }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.dealStages.moveDeals', + auditPayload(input, ['id', 'stage']), + 'completed', + ); + return response; + }; + +/** + * Deletes a pipeline stage, optionally moving its deals first. + * + * `action_type: 'Move'` requires both `new_pipeline_id` and `new_stage_id`; + * the input schema enforces that pairing with a refinement, because deleting a + * stage without relocating its deals destroys them. + */ +export const removeStageWithDeals: ActiveCampaignEndpoints['dealStagesDeleteWithDeals'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + + if (input.action_type === 'Move') { + await makeActiveCampaignRequest( + `dealStages/${input.id}/deals`, + ctx.key, + account, + { method: 'PUT', body: { deals: { stage: input.new_stage_id } } }, + ); + } + + await makeActiveCampaignRequest( + `dealStages/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.dealStages.deleteWithDeals', + auditPayload(input, [ + 'id', + 'action_type', + 'new_pipeline_id', + 'new_stage_id', + ]), + 'completed', + ); + return { id: input.id }; + }; + +/** + * Tasks against a contact. + * + * ActiveCampaign has no `/tasks` collection - it answers 404 - so contact + * tasks are deal tasks with `reltype: 'Subscriber'`. Exposed as contact-facing + * operations because that is how the catalog lists them. + */ +export const createContactTask: ActiveCampaignEndpoints['contactTasksCreate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactTasksCreate'] + >('dealTasks', ctx.key, account, { + method: 'POST', + body: { + dealTask: { + title: input.title, + relid: input.contactId, + reltype: 'Subscriber', + dealtasktype: input.taskTypeId, + duedate: input.dueDate, + ...(input.note !== undefined && { note: input.note }), + ...(input.assignee !== undefined && { assignee: input.assignee }), + }, + }, + }); + + await persistRow( + ctx.db.dealTasks, + ActiveCampaignDealTask, + response.dealTask, + 'dealTask', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contactTasks.create', + auditPayload(input, ['contactId', 'taskTypeId', 'dueDate', 'assignee']), + 'completed', + ); + return response; + }; + +/** + * Finds contact tasks by title, optionally narrowed to one contact. + * + * The collection has no title filter, so matching happens here; the title is + * caller-supplied text and only the match count is logged. + */ +export const findContactTask: ActiveCampaignEndpoints['contactTasksFind'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + type DealTaskRow = { + title?: string; + relid?: string; + reltype?: string; + }; + const dealTasks: DealTaskRow[] = []; + for (let offset = 0; ; ) { + const page = await makeActiveCampaignRequest<{ + dealTasks?: DealTaskRow[]; + }>('dealTasks', ctx.key, account, { + method: 'GET', + query: compactQuery({ + 'filters[reltype]': 'Subscriber', + 'filters[relid]': input.contactId, + limit: AC_PAGE_SIZE_MAX, + offset, + }), + }); + const rows = page.dealTasks ?? []; + dealTasks.push(...rows); + if (rows.length < AC_PAGE_SIZE_MAX) break; + offset += rows.length; + } + + const matches = dealTasks.filter( + (t) => t.title === input.title && t.reltype === 'Subscriber', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contactTasks.find', + { + contactId: input.contactId, + matched: matches.length, + fields: ['title'], + }, + 'completed', + ); + return { + dealTasks: matches, + } as ActiveCampaignEndpointOutputs['contactTasksFind']; + }; diff --git a/packages/activecampaign/endpoints/fields.ts b/packages/activecampaign/endpoints/fields.ts new file mode 100644 index 000000000..275ad1c64 --- /dev/null +++ b/packages/activecampaign/endpoints/fields.ts @@ -0,0 +1,577 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { + ActiveCampaignField, + ActiveCampaignFieldOption, + ActiveCampaignFieldRel, + ActiveCampaignFieldValue, + ActiveCampaignGroupMember, +} from '../schema/database'; +import { auditPayload, listAuditPayload } from './logging'; +import { evictChildren, evictRow, persistRow, persistRows } from './persist'; +import { buildPaginationQuery, compactBody, resolveAccount } from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +// --------------------------------------------------------------------------- +// Field definitions +// --------------------------------------------------------------------------- + +/** + * Custom field *definitions*. An agent needs the account's field schema to + * interpret the values attached to a contact, which is why the definitions are + * mirrored. + */ +export const list: ActiveCampaignEndpoints['fieldsList'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldsList'] + >('fields', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await persistRows( + ctx.db.fields, + ActiveCampaignField, + response.fields, + 'field', + ); + + await logEventFromContext( + ctx, + 'activecampaign.fields.list', + listAuditPayload(input, ['limit', 'offset'], response.fields?.length ?? 0), + 'completed', + ); + return response; +}; + +export const get: ActiveCampaignEndpoints['fieldsGet'] = async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldsGet'] + >(`fields/${input.id}`, ctx.key, account, { method: 'GET' }); + + await persistRow(ctx.db.fields, ActiveCampaignField, response.field, 'field'); + + await logEventFromContext( + ctx, + 'activecampaign.fields.get', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +export const create: ActiveCampaignEndpoints['fieldsCreate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldsCreate'] + >('fields', ctx.key, account, { + method: 'POST', + body: { + field: compactBody({ + title: input.title, + type: input.type, + descript: input.descript, + perstag: input.perstag, + defval: input.defval, + // ActiveCampaign expects its booleans as 0/1. + isrequired: + input.isrequired === undefined ? undefined : input.isrequired ? 1 : 0, + visible: + input.visible === undefined ? undefined : input.visible ? 1 : 0, + ordernum: input.ordernum, + }), + }, + }); + + await persistRow(ctx.db.fields, ActiveCampaignField, response.field, 'field'); + + await logEventFromContext( + ctx, + 'activecampaign.fields.create', + auditPayload(input, ['type', 'isrequired', 'visible', 'ordernum']), + 'completed', + ); + return response; +}; + +export const update: ActiveCampaignEndpoints['fieldsUpdate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldsUpdate'] + >(`fields/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { + field: compactBody({ + title: input.title, + type: input.type, + descript: input.descript, + perstag: input.perstag, + defval: input.defval, + isrequired: + input.isrequired === undefined ? undefined : input.isrequired ? 1 : 0, + visible: + input.visible === undefined ? undefined : input.visible ? 1 : 0, + ordernum: input.ordernum, + }), + }, + }); + + await persistRow(ctx.db.fields, ActiveCampaignField, response.field, 'field'); + + await logEventFromContext( + ctx, + 'activecampaign.fields.update', + auditPayload(input, ['id', 'type', 'isrequired', 'visible', 'ordernum']), + 'completed', + ); + return response; +}; + +/** + * Deleting a field definition also destroys every value stored against it + * upstream, so both the field and its cached values are evicted - leaving the + * values behind would let the mirror describe data that no longer exists. + */ +export const remove: ActiveCampaignEndpoints['fieldsDelete'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `fields/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await evictRow(ctx.db.fields, input.id, 'field'); + await evictChildren(ctx.db.fieldValues, 'field', input.id, 'fieldValue'); + + await logEventFromContext( + ctx, + 'activecampaign.fields.delete', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; +}; + +/** + * Creates options in bulk for a dropdown, radio, checkbox or listbox field. + * The field must already exist. + */ +export const createOptionsBulk: ActiveCampaignEndpoints['fieldOptionsCreateBulk'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldOptionsCreateBulk'] + >('fieldOption/bulk', ctx.key, account, { + method: 'POST', + body: { + fieldOptions: input.options.map((o) => + compactBody({ + field: o.field, + label: o.label, + value: o.value, + orderid: o.orderid, + isdefault: + o.isdefault === undefined ? undefined : o.isdefault ? 1 : 0, + }), + ), + }, + }); + + await persistRows( + ctx.db.fieldOptions, + ActiveCampaignFieldOption, + response.fieldOptions, + 'fieldOption', + ); + + await logEventFromContext( + ctx, + 'activecampaign.fieldOptions.createBulk', + { optionCount: input.options.length, fields: ['options'] }, + 'completed', + ); + return response; + }; + +// --------------------------------------------------------------------------- +// Field values +// --------------------------------------------------------------------------- + +export const listValues: ActiveCampaignEndpoints['fieldValuesList'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldValuesList'] + >('fieldValues', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await persistRows( + ctx.db.fieldValues, + ActiveCampaignFieldValue, + response.fieldValues, + 'fieldValue', + ); + + await logEventFromContext( + ctx, + 'activecampaign.fieldValues.list', + listAuditPayload( + input, + ['limit', 'offset'], + response.fieldValues?.length ?? 0, + ), + 'completed', + ); + return response; +}; + +export const getValue: ActiveCampaignEndpoints['fieldValuesGet'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldValuesGet'] + >(`fieldValues/${input.id}`, ctx.key, account, { method: 'GET' }); + + await persistRow( + ctx.db.fieldValues, + ActiveCampaignFieldValue, + response.fieldValue, + 'fieldValue', + ); + + await logEventFromContext( + ctx, + 'activecampaign.fieldValues.get', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +/** + * Sets a custom field value on a contact. + * + * The value is caller-supplied contact data, so it is never logged - only the + * contact and field identifiers are. + */ +export const setValueForContact: ActiveCampaignEndpoints['fieldValuesSetForContact'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldValuesSetForContact'] + >('fieldValues', ctx.key, account, { + method: 'POST', + body: { + fieldValue: { + contact: input.contact, + field: input.field, + value: input.value, + }, + // Sent explicitly rather than omitted, so the default-filling + // behaviour is the caller's decision and not inherited. + useDefaults: input.useDefaults ?? false, + }, + }); + + await persistRow( + ctx.db.fieldValues, + ActiveCampaignFieldValue, + response.fieldValue, + 'fieldValue', + ); + + await logEventFromContext( + ctx, + 'activecampaign.fieldValues.setForContact', + auditPayload(input, ['contact', 'field', 'useDefaults']), + 'completed', + ); + return response; + }; + +export const updateValue: ActiveCampaignEndpoints['fieldValuesUpdate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldValuesUpdate'] + >(`fieldValues/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { + fieldValue: { value: input.value }, + useDefaults: input.useDefaults ?? false, + }, + }); + + await persistRow( + ctx.db.fieldValues, + ActiveCampaignFieldValue, + response.fieldValue, + 'fieldValue', + ); + + await logEventFromContext( + ctx, + 'activecampaign.fieldValues.update', + auditPayload(input, ['id', 'useDefaults']), + 'completed', + ); + return response; +}; + +export const removeValue: ActiveCampaignEndpoints['fieldValuesDelete'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `fieldValues/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await evictRow(ctx.db.fieldValues, input.id, 'fieldValue'); + + await logEventFromContext( + ctx, + 'activecampaign.fieldValues.delete', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; +}; + +// --------------------------------------------------------------------------- +// Field relationships +// --------------------------------------------------------------------------- + +export const listRels: ActiveCampaignEndpoints['fieldRelsList'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldRelsList'] + >('fieldRels', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await persistRows( + ctx.db.fieldRels, + ActiveCampaignFieldRel, + response.fieldRels, + 'fieldRel', + ); + + await logEventFromContext( + ctx, + 'activecampaign.fieldRels.list', + listAuditPayload( + input, + ['limit', 'offset'], + response.fieldRels?.length ?? 0, + ), + 'completed', + ); + return response; +}; + +export const createRel: ActiveCampaignEndpoints['fieldRelsCreate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['fieldRelsCreate'] + >('fieldRels', ctx.key, account, { + method: 'POST', + body: { fieldRel: { field: input.field, relid: input.relid } }, + }); + + await persistRow( + ctx.db.fieldRels, + ActiveCampaignFieldRel, + response.fieldRel, + 'fieldRel', + ); + + await logEventFromContext( + ctx, + 'activecampaign.fieldRels.create', + auditPayload(input, ['field', 'relid']), + 'completed', + ); + return response; +}; + +export const removeRel: ActiveCampaignEndpoints['fieldRelsDelete'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `fieldRels/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await evictRow(ctx.db.fieldRels, input.id, 'fieldRel'); + + await logEventFromContext( + ctx, + 'activecampaign.fieldRels.delete', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; +}; + +// --------------------------------------------------------------------------- +// Field groups +// --------------------------------------------------------------------------- + +export const listGroupMembers: ActiveCampaignEndpoints['groupMembersList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['groupMembersList'] + >('groupMembers', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await persistRows( + ctx.db.groupMembers, + ActiveCampaignGroupMember, + response.groupMembers, + 'groupMember', + ); + + await logEventFromContext( + ctx, + 'activecampaign.groupMembers.list', + listAuditPayload( + input, + ['limit', 'offset'], + response.groupMembers?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +/** + * Adds a custom field to a display group, which is what makes it visible on + * contact and deal pages. Takes the field *relationship* id, not the field id. + */ +export const createGroupMember: ActiveCampaignEndpoints['groupMembersCreate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['groupMembersCreate'] + >('groupMembers', ctx.key, account, { + method: 'POST', + body: { + groupMember: compactBody({ + rel_id: input.rel_id, + group_id: input.group_id, + ordernum: input.ordernum, + }), + }, + }); + + await persistRow( + ctx.db.groupMembers, + ActiveCampaignGroupMember, + response.groupMember, + 'groupMember', + ); + + await logEventFromContext( + ctx, + 'activecampaign.groupMembers.create', + auditPayload(input, ['rel_id', 'group_id', 'ordernum']), + 'completed', + ); + return response; + }; + +export const updateGroupMember: ActiveCampaignEndpoints['groupMembersUpdate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['groupMembersUpdate'] + >(`groupMembers/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { + groupMember: compactBody({ + rel_id: input.rel_id, + group_id: input.group_id, + ordernum: input.ordernum, + }), + }, + }); + + await persistRow( + ctx.db.groupMembers, + ActiveCampaignGroupMember, + response.groupMember, + 'groupMember', + ); + + await logEventFromContext( + ctx, + 'activecampaign.groupMembers.update', + auditPayload(input, ['id', 'rel_id', 'group_id', 'ordernum']), + 'completed', + ); + return response; + }; + +export const removeGroupMember: ActiveCampaignEndpoints['groupMembersDelete'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `groupMembers/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await evictRow(ctx.db.groupMembers, input.id, 'groupMember'); + + await logEventFromContext( + ctx, + 'activecampaign.groupMembers.delete', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; + }; diff --git a/packages/activecampaign/endpoints/imports.ts b/packages/activecampaign/endpoints/imports.ts new file mode 100644 index 000000000..b5290259c --- /dev/null +++ b/packages/activecampaign/endpoints/imports.ts @@ -0,0 +1,108 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { compactBody, resolveAccount } from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +/** + * Queues contacts for asynchronous import. + * + * ActiveCampaign accepts up to 250 contacts per call below 400 KB and returns + * immediately with a batch id; the rows are written in the background. Nothing + * is mirrored here, because the response carries a batch receipt rather than + * the contacts themselves - the imported rows only become visible through the + * contact endpoints once processing finishes. + * + * `exclude_automations` is sent explicitly rather than omitted. Omitting it + * lets ActiveCampaign apply its own default, which runs every automation + * triggered by a list subscription - for a bulk import that can mean a + * very large amount of outbound mail. The safe value has to be chosen by the + * caller, so the default here is to exclude. + */ +export const createBulk: ActiveCampaignEndpoints['importsCreateBulk'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['importsCreateBulk'] + >('import/bulk_import', ctx.key, account, { + method: 'POST', + body: compactBody({ + contacts: input.contacts, + // List subscriptions are nested on each contact by the bulk-import API. + exclude_automations: input.exclude_automations ?? true, + callback: input.callback, + }), + }); + + // The contacts array is entirely personal data - emails, names, phone + // numbers - so only its size is recorded. + await logEventFromContext( + ctx, + 'activecampaign.imports.createBulk', + { + contactCount: input.contacts.length, + listCount: input.contacts.reduce( + (count, contact) => count + (contact.subscribe?.length ?? 0), + 0, + ), + excludeAutomations: input.exclude_automations ?? true, + hasCallback: input.callback !== undefined, + fields: ['contacts'], + }, + 'completed', + ); + return response; +}; + +/** + * Outstanding and recently completed import batches. ActiveCampaign returns a + * rolling window rather than full history. + */ +export const list: ActiveCampaignEndpoints['importsList'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['importsList'] + >('import/bulk_import', ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.imports.list', + auditPayload(input, []), + 'completed', + ); + return response; +}; + +/** + * Progress of a single batch. + * + * `batchId` is required - the endpoint answers 400 with + * "'batchId' is a required field." when it is absent, so it is a required + * input rather than an optional filter. + */ +export const getStatus: ActiveCampaignEndpoints['importsGetStatus'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['importsGetStatus'] + >('import/info', ctx.key, account, { + method: 'GET', + query: { batchId: input.batchId }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.imports.getStatus', + auditPayload(input, ['batchId']), + 'completed', + ); + return response; +}; diff --git a/packages/activecampaign/endpoints/index.ts b/packages/activecampaign/endpoints/index.ts new file mode 100644 index 000000000..8f60b8438 --- /dev/null +++ b/packages/activecampaign/endpoints/index.ts @@ -0,0 +1,21 @@ +import * as AccountsEndpoints from './accounts'; +import * as ContactsEndpoints from './contacts'; +import * as ContentEndpoints from './content'; +import * as DealsEndpoints from './deals'; +import * as FieldsEndpoints from './fields'; +import * as ImportsEndpoints from './imports'; +import * as ListsEndpoints from './lists'; +import * as PlatformEndpoints from './platform'; +import * as SegmentsV2Endpoints from './segments-v2'; +import * as TagsEndpoints from './tags'; + +export const Accounts = AccountsEndpoints; +export const Contacts = ContactsEndpoints; +export const Content = ContentEndpoints; +export const Deals = DealsEndpoints; +export const Lists = ListsEndpoints; +export const Platform = PlatformEndpoints; +export const SegmentsV2 = SegmentsV2Endpoints; +export const Tags = TagsEndpoints; +export const Fields = FieldsEndpoints; +export const Imports = ImportsEndpoints; diff --git a/packages/activecampaign/endpoints/lists.ts b/packages/activecampaign/endpoints/lists.ts new file mode 100644 index 000000000..d0c105768 --- /dev/null +++ b/packages/activecampaign/endpoints/lists.ts @@ -0,0 +1,227 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { + ActiveCampaignContactList, + ActiveCampaignList, + ActiveCampaignListGroup, +} from '../schema/database'; +import { auditPayload, listAuditPayload } from './logging'; +import { evictRow, persistRow, persistRows } from './persist'; +import { + buildPaginationQuery, + compactBody, + compactQuery, + resolveAccount, +} from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +export const list: ActiveCampaignEndpoints['listsList'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['listsList'] + >('lists', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + // ActiveCampaign filters list names through a `filters[name]` key + // rather than a bare `name` parameter. + ...compactQuery({ 'filters[name]': input.name }), + }, + }); + + await persistRows(ctx.db.lists, ActiveCampaignList, response.lists, 'list'); + + await logEventFromContext( + ctx, + 'activecampaign.lists.list', + listAuditPayload(input, ['limit', 'offset'], response.lists?.length ?? 0), + 'completed', + ); + return response; +}; + +export const get: ActiveCampaignEndpoints['listsGet'] = async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['listsGet'] + >(`lists/${input.id}`, ctx.key, account, { method: 'GET' }); + + await persistRow(ctx.db.lists, ActiveCampaignList, response.list, 'list'); + + await logEventFromContext( + ctx, + 'activecampaign.lists.get', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +export const create: ActiveCampaignEndpoints['listsCreate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['listsCreate'] + >('lists', ctx.key, account, { + method: 'POST', + body: { + list: compactBody({ + name: input.name, + stringid: input.stringid, + sender_url: input.sender_url, + sender_reminder: input.sender_reminder, + // Sent explicitly rather than omitted: ActiveCampaign's documented + // default for send_last_broadcast is true, which would mail the + // account's most recent broadcast to every new subscriber. A + // fail-safe default has to be sent, because omission inherits the + // provider's default rather than ours. + send_last_broadcast: input.send_last_broadcast ?? false, + carboncopy: input.carboncopy, + subscription_notify: input.subscription_notify, + unsubscription_notify: input.unsubscription_notify, + user: input.user, + }), + }, + }); + + await persistRow(ctx.db.lists, ActiveCampaignList, response.list, 'list'); + + await logEventFromContext( + ctx, + 'activecampaign.lists.create', + auditPayload(input, ['name', 'stringid', 'send_last_broadcast']), + 'completed', + ); + return response; +}; + +export const remove: ActiveCampaignEndpoints['listsDelete'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `lists/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await evictRow(ctx.db.lists, input.id, 'list'); + + await logEventFromContext( + ctx, + 'activecampaign.lists.delete', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; +}; + +export const listContactLists: ActiveCampaignEndpoints['contactListsList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactListsList'] + >('contactLists', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await persistRows( + ctx.db.contactLists, + ActiveCampaignContactList, + response.contactLists, + 'contactList', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contactLists.list', + listAuditPayload( + input, + ['limit', 'offset'], + response.contactLists?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +/** + * Subscribes (status 1) or unsubscribes (status 2) a contact. + * + * The association row is not deleted on an unsubscribe - ActiveCampaign keeps + * it so the history survives - so this never evicts from the mirror. + */ +export const updateSubscription: ActiveCampaignEndpoints['listsUpdateSubscription'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['listsUpdateSubscription'] + >('contactLists', ctx.key, account, { + method: 'POST', + body: { + contactList: { + list: input.list, + contact: input.contact, + status: input.status, + }, + }, + }); + + await persistRow( + ctx.db.contactLists, + ActiveCampaignContactList, + response.contactList, + 'contactList', + ); + + await logEventFromContext( + ctx, + 'activecampaign.lists.updateSubscription', + auditPayload(input, ['list', 'contact', 'status']), + 'completed', + ); + return response; + }; + +/** + * Grants a user group a set of permissions over a list. + * + * ActiveCampaign derives the individual permission flags from the account's + * defaults; this endpoint only takes the list and the group. + */ +export const createListGroup: ActiveCampaignEndpoints['listGroupsCreate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['listGroupsCreate'] + >('listGroups', ctx.key, account, { + method: 'POST', + body: { + listGroup: { listid: input.listid, groupid: input.groupid }, + }, + }); + + await persistRow( + ctx.db.listGroups, + ActiveCampaignListGroup, + response.listGroup, + 'listGroup', + ); + + await logEventFromContext( + ctx, + 'activecampaign.listGroups.create', + auditPayload(input, ['listid', 'groupid']), + 'completed', + ); + return response; + }; diff --git a/packages/activecampaign/endpoints/logging.ts b/packages/activecampaign/endpoints/logging.ts new file mode 100644 index 000000000..517aed127 --- /dev/null +++ b/packages/activecampaign/endpoints/logging.ts @@ -0,0 +1,49 @@ +/** + * Builds the payload recorded in `corsair_events` for an operation. + * + * ActiveCampaign inputs carry personal data - email addresses, first and last + * names, phone numbers, and free-text note bodies. None of that belongs in a + * durable event log, so this allow-lists the keys that may be recorded by + * value (identifiers, pagination, status flags) and records every other + * supplied key by *name only*, so an audit still shows which fields an + * operation touched without storing what was in them. + * + * `endpoints.test.ts` asserts the exact payload for the operations that accept + * personal data, because this is the one place caller-supplied text could + * reach durable storage. + */ +export function auditPayload( + input: Record, + allowedKeys: readonly string[], +): Record { + const payload: Record = {}; + const redactedFields: string[] = []; + + for (const [key, value] of Object.entries(input)) { + if (value === undefined) { + continue; + } + if (allowedKeys.includes(key)) { + payload[key] = value; + } else { + redactedFields.push(key); + } + } + + if (redactedFields.length > 0) { + payload.fields = redactedFields.sort(); + } + + return payload; +} + +/** + * Records how many rows a list operation returned, rather than the rows. + */ +export function listAuditPayload( + input: Record, + allowedKeys: readonly string[], + returnedCount: number, +): Record { + return { ...auditPayload(input, allowedKeys), returnedCount }; +} diff --git a/packages/activecampaign/endpoints/persist.ts b/packages/activecampaign/endpoints/persist.ts new file mode 100644 index 000000000..eb4dee042 --- /dev/null +++ b/packages/activecampaign/endpoints/persist.ts @@ -0,0 +1,196 @@ +import type { z } from 'zod'; + +/** + * Cache helpers for the local ActiveCampaign mirror. + * + * Two rules hold throughout: + * + * 1. A row is validated against its entity schema *before* it is written, so + * an unrecognised row is skipped rather than stored as something later + * reads cannot interpret. A skip warns - silence would turn a schema gap + * into a row that simply never appears. + * 2. Writes are best-effort. A plugin call must not fail because the local + * mirror could not be written, so every write is wrapped and a failure only + * warns. + */ + +/** A list page can run to hundreds of rows; cap the concurrent writes. */ +const WRITE_CONCURRENCY = 16; +/** Cap a child-eviction search so a parent cannot load an unbounded page. */ +const SEARCH_LIMIT = 1000; + +/** + * The subset of the entity store these helpers use. + * + * Declared with method shorthand rather than function properties on purpose: + * the real store types its `data` parameter as the specific entity, and under + * property syntax TypeScript checks parameters contravariantly, so a store for + * a concrete entity would not be assignable to a store for a generic row. + * Method shorthand is bivariant, which is what lets one helper serve all 43 + * entities. + */ +type Store = { + upsertByEntityId(entityId: string, data: never): Promise; + deleteByEntityId?(entityId: string): Promise; +}; + +/** + * The extra capability {@link evictChildren} needs: a search by stored field. + * Kept separate from {@link Store} so the common helpers do not require it. + */ +type ChildStore = Store & { + // `options` is deliberately `never` for the same reason `upsertByEntityId` + // takes `never` above: the real store types this against its own entity + // schema, and a wider parameter here would make no concrete store + // assignable. The filter is built and cast at the call site below. + search?(options: never): Promise>; +}; + +/** + * Validates and writes a single row. + */ +export async function persistRow( + store: Store | undefined, + schema: z.ZodType, + row: unknown, + entityName: string, +): Promise { + if (!store || row === null || row === undefined) { + return; + } + + const parsed = schema.safeParse(row); + if (!parsed.success) { + console.warn( + `[ACTIVECAMPAIGN] Skipped caching a ${entityName} row that did not match the entity schema: ${parsed.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('; ')}`, + ); + return; + } + + const data = parsed.data as Record; + const entityId = data.id; + if (typeof entityId !== 'string' || entityId.length === 0) { + console.warn( + `[ACTIVECAMPAIGN] Skipped caching a ${entityName} row with no usable id`, + ); + return; + } + + try { + await store.upsertByEntityId(entityId, data as never); + } catch (error) { + console.warn(`[ACTIVECAMPAIGN] Failed to cache ${entityName}:`, error); + } +} + +/** + * Validates and writes a page of rows, bounded to {@link WRITE_CONCURRENCY}. + */ +export async function persistRows( + store: Store | undefined, + schema: z.ZodType, + rows: unknown, + entityName: string, +): Promise { + if (!store || !Array.isArray(rows) || rows.length === 0) { + return; + } + + for (let i = 0; i < rows.length; i += WRITE_CONCURRENCY) { + const batch = rows.slice(i, i + WRITE_CONCURRENCY); + await Promise.all( + batch.map((row) => persistRow(store, schema, row, entityName)), + ); + } +} + +/** + * Removes a row from the mirror after the record is deleted upstream. + * + * Reads deliberately do not evict: ActiveCampaign archives far more often than + * it deletes, and a record that stops appearing in a filtered list is usually + * still a real record. An explicit DELETE is different - it is permanent, and + * leaving the row would let the mirror outlive the record it describes. + */ +export async function evictRow( + store: Store | undefined, + entityId: string, + entityName: string, +): Promise { + if (!store?.deleteByEntityId || !entityId) { + return; + } + try { + await store.deleteByEntityId(entityId); + } catch (error) { + console.warn( + `[ACTIVECAMPAIGN] Failed to evict ${entityName} ${entityId} from the cache:`, + error, + ); + } +} + +/** + * Removes every mirrored row that points at a deleted parent. + * + * Some deletions cascade upstream: removing a custom field destroys every + * value stored against it, and removing a tag removes every contact-tag + * association. Evicting only the parent would leave those children in the + * mirror describing something that no longer exists, which is the same + * staleness `evictRow` exists to prevent - just one level down. + * + * The store exposes `search`, so the children are found by their foreign key + * and evicted individually. Best-effort throughout: a mirror that cannot be + * cleaned must not fail the API call that already succeeded. + */ +export async function evictChildren( + store: ChildStore | undefined, + foreignKey: string, + parentId: string, + entityName: string, +): Promise { + if (!store?.search || !store.deleteByEntityId || !parentId) { + return; + } + const remove = store.deleteByEntityId; + + try { + const rows = await store.search({ + data: { [foreignKey]: parentId }, + limit: SEARCH_LIMIT, + } as never); + if (!Array.isArray(rows) || rows.length === 0) { + return; + } + + const ids: string[] = []; + for (const row of rows) { + const entityId = row?.entity_id; + if (typeof entityId !== 'string' || entityId.length === 0) continue; + ids.push(entityId); + } + + for (let i = 0; i < ids.length; i += WRITE_CONCURRENCY) { + const batch = ids.slice(i, i + WRITE_CONCURRENCY); + await Promise.all( + batch.map(async (entityId) => { + try { + await remove(entityId); + } catch (error) { + console.warn( + `[ACTIVECAMPAIGN] Failed to evict ${entityName} ${entityId} after its parent was deleted:`, + error, + ); + } + }), + ); + } + } catch (error) { + console.warn( + `[ACTIVECAMPAIGN] Could not look up ${entityName} rows to evict after a parent delete:`, + error, + ); + } +} diff --git a/packages/activecampaign/endpoints/platform.ts b/packages/activecampaign/endpoints/platform.ts new file mode 100644 index 000000000..fb7f98a72 --- /dev/null +++ b/packages/activecampaign/endpoints/platform.ts @@ -0,0 +1,1583 @@ +import { logEventFromContext } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { + makeActiveCampaignGraphQLRequest, + makeActiveCampaignRequest, +} from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { + ActiveCampaignAddress, + ActiveCampaignCalendar, + ActiveCampaignConnection, + ActiveCampaignCustomObjectSchema, + ActiveCampaignEcomCustomer, + ActiveCampaignEventTrackingEvent, + ActiveCampaignGroup, + ActiveCampaignUser, + ActiveCampaignWebhook, +} from '../schema/database'; +import { auditPayload, listAuditPayload } from './logging'; +import { makeResource } from './resource'; +import { + buildPaginationQuery, + compactBody, + compactQuery, + resolveAccount, +} from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +/** + * E-commerce, custom objects, tracking, webhooks and account administration. + * + * The e-commerce catalog (products, bulk order upsert, recurring payments, + * browse sessions) is GraphQL rather than REST, so those operations use the + * GraphQL transport. Everything else here is standard v3 REST. + */ + +// --------------------------------------------------------------------------- +// E-commerce, REST +// --------------------------------------------------------------------------- + +const connections = makeResource({ + path: 'connections', + one: 'connection', + many: 'connections', + event: 'activecampaign.connections', + entity: ActiveCampaignConnection, + store: 'connections', + label: 'connection', + logKeys: ['id', 'limit', 'offset', 'service', 'externalid'], + bodyKeys: ['service', 'externalid', 'name', 'logoUrl', 'linkUrl'], +}); + +const ecomCustomers = makeResource({ + path: 'ecomCustomers', + one: 'ecomCustomer', + many: 'ecomCustomers', + event: 'activecampaign.ecomCustomers', + entity: ActiveCampaignEcomCustomer, + store: 'ecomCustomers', + label: 'ecomCustomer', + // `email` is the customer's own data and is never logged by value. + logKeys: ['id', 'limit', 'offset', 'connectionid', 'externalid'], + bodyKeys: ['connectionid', 'externalid', 'email', 'acceptsMarketing'], +}); + +/** + * Orders are transactional - appended continuously and only meaningful against + * a date range - so they are returned but never mirrored. + */ +const ecomOrders = makeResource({ + path: 'ecomOrders', + one: 'ecomOrder', + many: 'ecomOrders', + event: 'activecampaign.ecomOrders', + label: 'ecomOrder', + logKeys: ['id', 'limit', 'offset', 'connectionid', 'customerid'], + bodyKeys: [ + 'externalid', + 'source', + 'email', + 'orderProducts', + 'orderDiscounts', + 'totalPrice', + 'shippingAmount', + 'taxAmount', + 'discountAmount', + 'currency', + 'orderDate', + 'externalUpdatedDate', + 'abandonedDate', + 'externalcheckoutid', + 'connectionid', + 'customerid', + 'orderNumber', + 'shippingMethod', + ], +}); + +const ecomOrderProducts = makeResource({ + path: 'ecomOrderProducts', + one: 'ecomOrderProduct', + many: 'ecomOrderProducts', + event: 'activecampaign.ecomOrderProducts', + label: 'ecomOrderProduct', +}); + +// --------------------------------------------------------------------------- +// Custom objects, tracking, webhooks, administration +// --------------------------------------------------------------------------- + +const customObjects = makeResource({ + path: 'customObjects/schemas', + one: 'schema', + many: 'schemas', + event: 'activecampaign.customObjectSchemas', + entity: ActiveCampaignCustomObjectSchema, + store: 'customObjectSchemas', + label: 'customObjectSchema', + bodyKeys: [ + 'slug', + 'name', + 'description', + 'labels', + 'fields', + 'relationships', + ], +}); + +const webhooks = makeResource({ + path: 'webhooks', + one: 'webhook', + many: 'webhooks', + event: 'activecampaign.webhooks', + entity: ActiveCampaignWebhook, + store: 'webhooks', + label: 'webhook', + logKeys: ['id', 'limit', 'offset', 'listid'], + bodyKeys: ['name', 'url', 'events', 'sources', 'listid'], +}); + +const users = makeResource({ + path: 'users', + one: 'user', + many: 'users', + event: 'activecampaign.users', + entity: ActiveCampaignUser, + store: 'users', + label: 'user', + // email, firstName, lastName and phone are staff personal data. + logKeys: ['id', 'limit', 'offset', 'group'], + bodyKeys: [ + 'username', + 'email', + 'firstName', + 'lastName', + 'password', + 'group', + 'phone', + 'signature', + 'lang', + 'localZoneid', + ], +}); + +const groups = makeResource({ + path: 'groups', + one: 'group', + many: 'groups', + event: 'activecampaign.groups', + entity: ActiveCampaignGroup, + store: 'groups', + label: 'group', + bodyKeys: ['title', 'descript'], +}); + +const addresses = makeResource({ + path: 'addresses', + one: 'address', + many: 'addresses', + event: 'activecampaign.addresses', + entity: ActiveCampaignAddress, + store: 'addresses', + label: 'address', + // Street address fields are personal data on a sole-trader account. + logKeys: ['id', 'limit', 'offset', 'country'], + bodyKeys: [ + 'companyName', + 'address1', + 'address2', + 'city', + 'state', + 'zip', + 'country', + 'allgroups', + 'groupid', + ], +}); + +const calendars = makeResource({ + path: 'calendars', + one: 'calendar', + many: 'calendars', + event: 'activecampaign.calendars', + entity: ActiveCampaignCalendar, + store: 'calendars', + label: 'calendar', + bodyKeys: ['title', 'type', 'description', 'isglobal', 'inviteusers'], +}); + +const eventTrackingEvents = makeResource({ + path: 'eventTrackingEvents', + one: 'eventTrackingEvent', + many: 'eventTrackingEvents', + event: 'activecampaign.eventTrackingEvents', + entity: ActiveCampaignEventTrackingEvent, + store: 'eventTrackingEvents', + label: 'eventTrackingEvent', + bodyKeys: ['name'], +}); + +// --- e-commerce REST exports ------------------------------------------------ +export const listConnections = + connections.list as ActiveCampaignEndpoints['connectionsList']; +export const getConnection = + connections.get as ActiveCampaignEndpoints['connectionsGet']; +export const createConnection = + connections.create as ActiveCampaignEndpoints['connectionsCreate']; +export const updateConnection = + connections.update as ActiveCampaignEndpoints['connectionsUpdate']; +export const removeConnection = + connections.remove as ActiveCampaignEndpoints['connectionsDelete']; + +export const listCustomers = + ecomCustomers.list as ActiveCampaignEndpoints['ecomCustomersList']; +export const getCustomer = + ecomCustomers.get as ActiveCampaignEndpoints['ecomCustomersGet']; +export const createCustomer = + ecomCustomers.create as ActiveCampaignEndpoints['ecomCustomersCreate']; +export const updateCustomer = + ecomCustomers.update as ActiveCampaignEndpoints['ecomCustomersUpdate']; +export const removeCustomer = + ecomCustomers.remove as ActiveCampaignEndpoints['ecomCustomersDelete']; + +export const listOrders = + ecomOrders.list as ActiveCampaignEndpoints['ecomOrdersList']; +export const getOrder = + ecomOrders.get as ActiveCampaignEndpoints['ecomOrdersGet']; +export const createOrder = + ecomOrders.create as ActiveCampaignEndpoints['ecomOrdersCreate']; +export const updateOrder = + ecomOrders.update as ActiveCampaignEndpoints['ecomOrdersUpdate']; +export const removeOrder = + ecomOrders.remove as ActiveCampaignEndpoints['ecomOrdersDelete']; + +export const listOrderProducts = + ecomOrderProducts.list as ActiveCampaignEndpoints['ecomOrderProductsList']; +export const getOrderProduct = + ecomOrderProducts.get as ActiveCampaignEndpoints['ecomOrderProductsGet']; + +// --- custom objects, webhooks, admin exports -------------------------------- +export const listSchemas = + customObjects.list as ActiveCampaignEndpoints['customObjectSchemasList']; +export const getSchema = + customObjects.get as ActiveCampaignEndpoints['customObjectSchemasGet']; +export const createSchema = + customObjects.create as ActiveCampaignEndpoints['customObjectSchemasCreate']; +export const updateSchema = + customObjects.update as ActiveCampaignEndpoints['customObjectSchemasUpdate']; +export const removeSchema = + customObjects.remove as ActiveCampaignEndpoints['customObjectSchemasDelete']; + +export const listWebhooks = + webhooks.list as ActiveCampaignEndpoints['webhooksList']; +export const getWebhook = + webhooks.get as ActiveCampaignEndpoints['webhooksGet']; +export const createWebhook = + webhooks.create as ActiveCampaignEndpoints['webhooksCreate']; +export const updateWebhook = + webhooks.update as ActiveCampaignEndpoints['webhooksUpdate']; +export const removeWebhook = + webhooks.remove as ActiveCampaignEndpoints['webhooksDelete']; + +export const listUsers = users.list as ActiveCampaignEndpoints['usersList']; +export const getUser = users.get as ActiveCampaignEndpoints['usersGet']; +export const createUser = + users.create as ActiveCampaignEndpoints['usersCreate']; +export const updateUser = + users.update as ActiveCampaignEndpoints['usersUpdate']; +export const removeUser = + users.remove as ActiveCampaignEndpoints['usersDelete']; + +export const listGroups = groups.list as ActiveCampaignEndpoints['groupsList']; +export const getGroup = groups.get as ActiveCampaignEndpoints['groupsGet']; +export const createGroup = + groups.create as ActiveCampaignEndpoints['groupsCreate']; +export const updateGroup = + groups.update as ActiveCampaignEndpoints['groupsUpdate']; +export const removeGroup = + groups.remove as ActiveCampaignEndpoints['groupsDelete']; + +export const listAddresses = + addresses.list as ActiveCampaignEndpoints['addressesList']; +export const getAddress = + addresses.get as ActiveCampaignEndpoints['addressesGet']; +export const createAddress = + addresses.create as ActiveCampaignEndpoints['addressesCreate']; +export const updateAddress = + addresses.update as ActiveCampaignEndpoints['addressesUpdate']; +export const removeAddress = + addresses.remove as ActiveCampaignEndpoints['addressesDelete']; + +export const listCalendars = + calendars.list as ActiveCampaignEndpoints['calendarsList']; +export const getCalendar = + calendars.get as ActiveCampaignEndpoints['calendarsGet']; +export const createCalendar = + calendars.create as ActiveCampaignEndpoints['calendarsCreate']; +export const updateCalendar = + calendars.update as ActiveCampaignEndpoints['calendarsUpdate']; +export const removeCalendar = + calendars.remove as ActiveCampaignEndpoints['calendarsDelete']; + +export const listEvents = + eventTrackingEvents.list as ActiveCampaignEndpoints['eventTrackingEventsList']; +export const createEvent = + eventTrackingEvents.create as ActiveCampaignEndpoints['eventTrackingEventsCreate']; +export const removeEvent = + eventTrackingEvents.remove as ActiveCampaignEndpoints['eventTrackingEventsDelete']; + +// --------------------------------------------------------------------------- +// GraphQL: products, bulk orders, recurring payments, browse sessions +// --------------------------------------------------------------------------- + +/** + * Runs a GraphQL document and unwraps the named field from `data`. + * + * The eComm GraphQL API answers 200 with an `errors` array rather than an HTTP + * error status, so a GraphQL-level failure would otherwise pass silently + * through the status-based error handlers. That is raised here instead. + */ +async function graphql( + ctx: { + key: string; + options: { account?: string }; + keys: { get_account: () => Promise }; + }, + query: string, + variables: Record, +): Promise { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignGraphQLRequest<{ + data?: T; + errors?: Array<{ message?: string }>; + }>(query, ctx.key, account, variables); + + if (response.errors?.length) { + const detail = response.errors + .map((e) => e.message ?? 'unknown error') + .join('; '); + throw new Error(`ActiveCampaign GraphQL error: ${detail}`); + } + return (response.data ?? {}) as T; +} + +const PRODUCT_FIELDS = + 'id name description imageUrl productUrl price currency sku isVariant'; + +export const searchProducts: ActiveCampaignEndpoints['productsSearch'] = async ( + ctx, + input, +) => { + const data = await graphql( + ctx, + `query SearchProducts($filter: ProductFilter, $limit: Int, $offset: Int) { + products(filter: $filter, limit: $limit, offset: $offset) { ${PRODUCT_FIELDS} } + }`, + compactBody({ + filter: input.filter, + limit: input.limit, + offset: input.offset, + }), + ); + + await logEventFromContext( + ctx, + 'activecampaign.products.search', + auditPayload(input, ['limit', 'offset']), + 'completed', + ); + return data; +}; + +export const getProduct: ActiveCampaignEndpoints['productsGet'] = async ( + ctx, + input, +) => { + const data = await graphql( + ctx, + `query GetProduct($id: ID!) { product(id: $id) { ${PRODUCT_FIELDS} } }`, + { id: input.id }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.products.get', + auditPayload(input, ['id']), + 'completed', + ); + return data; +}; + +export const createProduct: ActiveCampaignEndpoints['productsCreate'] = async ( + ctx, + input, +) => { + const data = await graphql( + ctx, + `mutation CreateProduct($input: CreateProductInput!) { + createProduct(input: $input) { ${PRODUCT_FIELDS} } + }`, + { input: compactBody({ ...input }) }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.products.create', + auditPayload(input, ['legacyConnectionId', 'sku', 'currency']), + 'completed', + ); + return data; +}; + +export const updateProduct: ActiveCampaignEndpoints['productsUpdate'] = async ( + ctx, + input, +) => { + const data = await graphql( + ctx, + `mutation UpdateProduct($input: UpdateProductInput!) { + updateProduct(input: $input) { ${PRODUCT_FIELDS} } + }`, + { input: compactBody({ ...input }) }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.products.update', + auditPayload(input, ['id', 'legacyConnectionId', 'sku']), + 'completed', + ); + return data; +}; + +export const removeProduct: ActiveCampaignEndpoints['productsDelete'] = async ( + ctx, + input, +) => { + const data = await graphql( + ctx, + 'mutation DeleteProduct($id: ID!) { deleteProduct(id: $id) { id } }', + { id: input.id }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.products.delete', + auditPayload(input, ['id']), + 'completed', + ); + return data; +}; + +export const upsertProductsBulk: ActiveCampaignEndpoints['productsUpsertBulk'] = + async (ctx, input) => { + const data = await graphql< + ActiveCampaignEndpointOutputs['productsUpsertBulk'] + >( + ctx, + `mutation BulkUpsertProducts($input: BulkUpsertProductsInput!) { + bulkUpsertProducts(input: $input) { id } + }`, + { input: { products: input.products } }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.products.upsertBulk', + { productCount: input.products.length, fields: ['products'] }, + 'completed', + ); + return data; + }; + +/** + * Bulk order upsert. Orders are matched on `storeOrderId` within a connection. + * + * The async variant writes to the data store in the background and is what + * ActiveCampaign recommends for any store of real volume; the synchronous one + * is kept for callers that need the write confirmed before returning. + */ +function bulkUpsertOrders< + K extends 'ordersUpsertBulk' | 'ordersUpsertBulkAsync', +>(mutation: string, event: string): ActiveCampaignEndpoints[K] { + return (async ( + ctx: Parameters[0], + input: { orders: unknown[] }, + ) => { + const data = await graphql( + ctx, + `mutation UpsertOrders($input: ${mutation}Input!) { + ${mutation}(input: $input) { id } + }`, + { input: { orders: input.orders } }, + ); + + await logEventFromContext( + ctx, + event, + { orderCount: input.orders.length, fields: ['orders'] }, + 'completed', + ); + return data; + }) as ActiveCampaignEndpoints[K]; +} + +export const upsertOrdersBulk = bulkUpsertOrders<'ordersUpsertBulk'>( + 'bulkUpsertOrders', + 'activecampaign.orders.upsertBulk', +); +export const upsertOrdersBulkAsync = bulkUpsertOrders<'ordersUpsertBulkAsync'>( + 'bulkUpsertOrdersAsync', + 'activecampaign.orders.upsertBulkAsync', +); + +export const searchRecurringPayments: ActiveCampaignEndpoints['recurringPaymentsSearch'] = + async (ctx, input) => { + const data = await graphql< + ActiveCampaignEndpointOutputs['recurringPaymentsSearch'] + >( + ctx, + `query SearchRecurringPayments($filter: RecurringPaymentFilter, $limit: Int, $offset: Int) { + recurringPayments(filter: $filter, limit: $limit, offset: $offset) { + id status currency amount + } + }`, + compactBody({ + filter: input.filter, + limit: input.limit, + offset: input.offset, + }), + ); + + await logEventFromContext( + ctx, + 'activecampaign.recurringPayments.search', + auditPayload(input, ['limit', 'offset']), + 'completed', + ); + return data; + }; + +export const upsertRecurringPaymentsBulk: ActiveCampaignEndpoints['recurringPaymentsUpsertBulk'] = + async (ctx, input) => { + const data = await graphql< + ActiveCampaignEndpointOutputs['recurringPaymentsUpsertBulk'] + >( + ctx, + `mutation BulkUpsertRecurringPayments($input: BulkUpsertRecurringPaymentsInput!) { + bulkUpsertRecurringPayments(input: $input) { id } + }`, + { input: { recurringPayments: input.recurringPayments } }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.recurringPayments.upsertBulk', + { + paymentCount: input.recurringPayments.length, + fields: ['recurringPayments'], + }, + 'completed', + ); + return data; + }; + +export const searchBrowseSessions: ActiveCampaignEndpoints['browseSessionsSearch'] = + async (ctx, input) => { + const data = await graphql< + ActiveCampaignEndpointOutputs['browseSessionsSearch'] + >( + ctx, + `query SearchBrowseSessions($filter: BrowseSessionFilter!) { + browseSessions(filter: $filter) { id status addedToCart } + }`, + { filter: compactBody({ ...input }) }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.browseSessions.search', + auditPayload(input, ['connectionId', 'status']), + 'completed', + ); + return data; + }; + +export const saveBrowseSession: ActiveCampaignEndpoints['browseSessionsSave'] = + async (ctx, input) => { + const data = await graphql< + ActiveCampaignEndpointOutputs['browseSessionsSave'] + >( + ctx, + `mutation SaveBrowseSession($input: SaveBrowseSessionInput!) { + saveBrowseSession(input: $input) { id status } + }`, + { input: compactBody({ ...input }) }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.browseSessions.save', + auditPayload(input, ['connectionId', 'status']), + 'completed', + ); + return data; + }; + +export const addBrowseSessionToCart: ActiveCampaignEndpoints['browseSessionsAddToCart'] = + async (ctx, input) => { + const data = await graphql< + ActiveCampaignEndpointOutputs['browseSessionsAddToCart'] + >( + ctx, + `mutation AddBrowseSessionToCart($input: AddToCartInput!) { + addBrowseSessionToCart(input: $input) { id addedToCart } + }`, + { input: compactBody({ ...input }) }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.browseSessions.addToCart', + auditPayload(input, ['connectionId']), + 'completed', + ); + return data; + }; + +// --------------------------------------------------------------------------- +// Tracking and account settings +// --------------------------------------------------------------------------- + +/** + * Site and event tracking status. Both are singleton settings rather than + * collections, so neither takes an id. + */ +function trackingStatus< + K extends 'trackingGetSiteStatus' | 'trackingGetEventStatus', +>(path: string, event: string): ActiveCampaignEndpoints[K] { + return (async (ctx: Parameters[0]) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs[K] + >(path, ctx.key, account, { method: 'GET' }); + + await logEventFromContext(ctx, event, {}, 'completed'); + return response; + }) as ActiveCampaignEndpoints[K]; +} + +export const getSiteTrackingStatus = trackingStatus<'trackingGetSiteStatus'>( + 'siteTracking', + 'activecampaign.tracking.getSiteStatus', +); +export const getEventTrackingStatus = trackingStatus<'trackingGetEventStatus'>( + 'eventTracking', + 'activecampaign.tracking.getEventStatus', +); + +function setTrackingStatus< + K extends 'trackingSetSiteStatus' | 'trackingSetEventStatus', +>(path: string, envelope: string, event: string): ActiveCampaignEndpoints[K] { + return (async ( + ctx: Parameters[0], + input: { enabled: boolean }, + ) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs[K] + >(path, ctx.key, account, { + method: 'PUT', + body: { [envelope]: { enabled: input.enabled } }, + }); + + await logEventFromContext( + ctx, + event, + auditPayload(input, ['enabled']), + 'completed', + ); + return response; + }) as ActiveCampaignEndpoints[K]; +} + +export const setSiteTrackingStatus = setTrackingStatus<'trackingSetSiteStatus'>( + 'siteTracking', + 'siteTracking', + 'activecampaign.tracking.setSiteStatus', +); +export const setEventTrackingStatus = + setTrackingStatus<'trackingSetEventStatus'>( + 'eventTracking', + 'eventTracking', + 'activecampaign.tracking.setEventStatus', + ); + +/** + * Records a custom event against a contact. + * + * Event tracking uses a separate host and form encoding rather than the v3 + * JSON API, and needs the account's event key alongside the actor id. Both are + * caller-supplied because neither is derivable from the API token. + */ +export const trackEvent: ActiveCampaignEndpoints['trackingTrackEvent'] = async ( + ctx, + input, +) => { + const body = new URLSearchParams({ + actid: input.actid, + key: input.key, + event: input.event, + visit: JSON.stringify({ email: input.email }), + ...(input.eventdata !== undefined && { eventdata: input.eventdata }), + }); + + const res = await fetch('https://trackcmp.net/event', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(20000), + }); + if (!res.ok) { + const body = await res.text(); + throw new ApiError( + { method: 'POST', url: 'https://trackcmp.net/event' }, + { + url: 'https://trackcmp.net/event', + ok: false, + status: res.status, + statusText: res.statusText, + body, + }, + `ActiveCampaign tracking request failed: ${res.status}`, + ); + } + const parsed = + (await res.json()) as ActiveCampaignEndpointOutputs['trackingTrackEvent']; + + // The event name and contact email are caller data; only the outcome and + // the field names are recorded. + await logEventFromContext( + ctx, + 'activecampaign.tracking.trackEvent', + { status: res.status, fields: ['actid', 'key', 'event', 'email'] }, + 'completed', + ); + return parsed; +}; + +export const listWhitelistedDomains: ActiveCampaignEndpoints['trackingListWhitelist'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['trackingListWhitelist'] + >('siteTrackingWhitelist', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + 'activecampaign.tracking.listWhitelist', + listAuditPayload( + input, + ['limit', 'offset'], + response.siteTrackingWhitelist?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +export const addWhitelistedDomain: ActiveCampaignEndpoints['trackingAddWhitelist'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['trackingAddWhitelist'] + >('siteTracking/whitelist', ctx.key, account, { + method: 'POST', + body: { siteTrackingWhitelist: { name: input.name } }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.tracking.addWhitelist', + auditPayload(input, ['name']), + 'completed', + ); + return response; + }; + +export const removeWhitelistedDomain: ActiveCampaignEndpoints['trackingRemoveWhitelist'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `siteTracking/whitelist/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.tracking.removeWhitelist', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; + }; + +// --- misc account settings -------------------------------------------------- + +export const getLoggedInUser: ActiveCampaignEndpoints['usersGetMe'] = async ( + ctx, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['usersGetMe'] + >('users/me', ctx.key, account, { method: 'GET' }); + + await logEventFromContext(ctx, 'activecampaign.users.getMe', {}, 'completed'); + return response; +}; + +export const getUserByUsername: ActiveCampaignEndpoints['usersGetByUsername'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['usersGetByUsername'] + >( + `users/username/${encodeURIComponent(input.username)}`, + ctx.key, + account, + { + method: 'GET', + }, + ); + + // The username identifies a person, so only the field name is logged. + await logEventFromContext( + ctx, + 'activecampaign.users.getByUsername', + { fields: ['username'] }, + 'completed', + ); + return response; + }; + +export const listGroupLimits: ActiveCampaignEndpoints['groupLimitsList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['groupLimitsList'] + >('groupLimits', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + 'activecampaign.groupLimits.list', + listAuditPayload( + input, + ['limit', 'offset'], + response.groupLimits?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +export const listScores: ActiveCampaignEndpoints['scoresList'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['scoresList'] + >('scores', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + 'activecampaign.scores.list', + listAuditPayload(input, ['limit', 'offset'], response.scores?.length ?? 0), + 'completed', + ); + return response; +}; + +/** + * Email activity is transactional and can be very large, so ActiveCampaign + * expects a subscriber or deal filter. Neither is required by the API, but + * omitting both degrades badly, so the input documents that. + */ +export const listEmailActivities: ActiveCampaignEndpoints['emailActivitiesList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['emailActivitiesList'] + >('emailActivities', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ + 'filters[subscriberid]': input.subscriberid, + 'filters[dealId]': input.dealId, + }), + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.emailActivities.list', + listAuditPayload( + input, + ['subscriberid', 'dealId', 'limit', 'offset'], + response.emailActivities?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +export const getBranding: ActiveCampaignEndpoints['brandingsGet'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['brandingsGet'] + >(`brandings/${input.id}`, ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.brandings.get', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +export const updateBranding: ActiveCampaignEndpoints['brandingsUpdate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['brandingsUpdate'] + >(`brandings/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { + branding: compactBody({ + siteName: input.siteName, + siteLogo: input.siteLogo, + favicon: input.favicon, + copyright: input.copyright, + }), + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.brandings.update', + auditPayload(input, ['id']), + 'completed', + ); + return response; + }; + +export const updateConfig: ActiveCampaignEndpoints['configsUpdate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['configsUpdate'] + >(`configs/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { config: { value: input.value } }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.configs.update', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +/** Custom object records, keyed either by internal id or by external id. */ +export const upsertRecord: ActiveCampaignEndpoints['customObjectRecordsUpsert'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['customObjectRecordsUpsert'] + >(`customObjects/records/${input.schemaId}`, ctx.key, account, { + method: 'POST', + body: compactBody({ + externalId: input.externalId, + fields: input.fields, + }), + }); + + await logEventFromContext( + ctx, + 'activecampaign.customObjectRecords.upsert', + auditPayload(input, ['schemaId', 'externalId']), + 'completed', + ); + return response; + }; + +export const listRecords: ActiveCampaignEndpoints['customObjectRecordsList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['customObjectRecordsList'] + >(`customObjects/records/${input.schemaId}`, ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + 'activecampaign.customObjectRecords.list', + auditPayload(input, ['schemaId', 'limit', 'offset']), + 'completed', + ); + return response; + }; + +function recordByKey< + K extends + | 'customObjectRecordsGet' + | 'customObjectRecordsGetByExternalId' + | 'customObjectRecordsDelete' + | 'customObjectRecordsDeleteByExternalId', +>( + segment: 'id' | 'externalId', + method: 'GET' | 'DELETE', + event: string, +): ActiveCampaignEndpoints[K] { + return (async ( + ctx: Parameters[0], + input: { schemaId: string; id?: string; externalId?: string }, + ) => { + const account = await resolveAccount(ctx); + const key = segment === 'id' ? input.id : input.externalId; + if (typeof key !== 'string' || key.length === 0) { + throw new Error( + segment === 'id' + ? 'A custom object record id is required' + : 'A custom object record externalId is required', + ); + } + const path = + segment === 'id' + ? `customObjects/records/${input.schemaId}/${encodeURIComponent(key)}` + : `customObjects/records/${input.schemaId}/externalid/${encodeURIComponent(key)}`; + + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs[K] + >(path, ctx.key, account, { method }); + + await logEventFromContext( + ctx, + event, + auditPayload(input, ['schemaId', 'id', 'externalId']), + 'completed', + ); + return method === 'DELETE' + ? ({ + schemaId: input.schemaId, + ...(segment === 'id' ? { id: key } : { externalId: key }), + } as ActiveCampaignEndpointOutputs[K]) + : response; + }) as ActiveCampaignEndpoints[K]; +} + +export const getRecord = recordByKey<'customObjectRecordsGet'>( + 'id', + 'GET', + 'activecampaign.customObjectRecords.get', +); +export const getRecordByExternalId = + recordByKey<'customObjectRecordsGetByExternalId'>( + 'externalId', + 'GET', + 'activecampaign.customObjectRecords.getByExternalId', + ); +export const removeRecord = recordByKey<'customObjectRecordsDelete'>( + 'id', + 'DELETE', + 'activecampaign.customObjectRecords.delete', +); +export const removeRecordByExternalId = + recordByKey<'customObjectRecordsDeleteByExternalId'>( + 'externalId', + 'DELETE', + 'activecampaign.customObjectRecords.deleteByExternalId', + ); + +// --------------------------------------------------------------------------- +// SMS +// +// SMS lives under `sms/*` rather than the `smsBroadcasts` collection the +// naming elsewhere would suggest - confirmed against the live API on +// 2026-08-14. `sms/broadcasts/metrics` answered 503 on that account rather +// than 404, so the route exists but the service was unavailable; it is +// implemented and its shape is left unmodelled. +// --------------------------------------------------------------------------- + +export const listSmsBroadcasts: ActiveCampaignEndpoints['smsBroadcastsList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['smsBroadcastsList'] + >('sms/broadcasts', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ name: input.name, status: input.status }), + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.smsBroadcasts.list', + listAuditPayload( + input, + ['limit', 'offset', 'status'], + response.broadcasts?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +export const getSmsCredits: ActiveCampaignEndpoints['smsCreditsGet'] = async ( + ctx, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['smsCreditsGet'] + >('sms/credits', ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.smsCredits.get', + {}, + 'completed', + ); + return response; +}; + +export const getSmsMetricsSnapshot: ActiveCampaignEndpoints['smsBroadcastsGetSnapshot'] = + async (ctx) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['smsBroadcastsGetSnapshot'] + >('sms/broadcasts/metrics/snapshot', ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.smsBroadcasts.getSnapshot', + {}, + 'completed', + ); + return response; + }; + +export const createSmsMetricsSnapshot: ActiveCampaignEndpoints['smsBroadcastsCreateSnapshot'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['smsBroadcastsCreateSnapshot'] + >('sms/broadcasts/metrics/snapshot', ctx.key, account, { + method: 'POST', + body: { broadcastIds: input.broadcastIds }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.smsBroadcasts.createSnapshot', + { broadcastCount: input.broadcastIds.length }, + 'completed', + ); + return response; + }; + +export const getSmsMetrics: ActiveCampaignEndpoints['smsBroadcastsGetMetrics'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['smsBroadcastsGetMetrics'] + >('sms/broadcasts/metrics', ctx.key, account, { + method: 'GET', + query: compactQuery({ broadcastIds: input.broadcastIds?.join(',') }), + }); + + await logEventFromContext( + ctx, + 'activecampaign.smsBroadcasts.getMetrics', + { broadcastCount: input.broadcastIds?.length ?? 0 }, + 'completed', + ); + return response; + }; + +export const getSmsFailures: ActiveCampaignEndpoints['smsBroadcastsGetFailures'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['smsBroadcastsGetFailures'] + >('sms/broadcasts/metrics/failures', ctx.key, account, { + method: 'GET', + query: compactQuery({ + broadcastId: input.broadcastId, + startDate: input.startDate, + endDate: input.endDate, + }), + }); + + await logEventFromContext( + ctx, + 'activecampaign.smsBroadcasts.getFailures', + auditPayload(input, ['broadcastId', 'startDate', 'endDate']), + 'completed', + ); + return response; + }; + +/** + * Recipients of one SMS broadcast. Rows carry phone numbers, so only the + * returned count reaches the event log. + */ +export const getSmsRecipients: ActiveCampaignEndpoints['smsBroadcastsGetRecipients'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['smsBroadcastsGetRecipients'] + >(`sms/broadcasts/${input.id}/recipients`, ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + 'activecampaign.smsBroadcasts.getRecipients', + listAuditPayload(input, ['id', 'limit', 'offset'], 0), + 'completed', + ); + return response; + }; + +/** + * The JavaScript snippet to embed for site tracking. Lives at + * `siteTracking/code`, not the `siteTrackingCode` the naming would suggest. + */ +export const getSiteTrackingCode: ActiveCampaignEndpoints['trackingGetCode'] = + async (ctx) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['trackingGetCode'] + >('siteTracking/code', ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.tracking.getCode', + {}, + 'completed', + ); + return response; + }; + +// --------------------------------------------------------------------------- +// Late additions - routes confirmed on 2026-08-14 after an initial probe of a +// wrong path suggested they were unavailable. +// --------------------------------------------------------------------------- + +/** SMS broadcast lists live at `sms/lists`, under the `lists` envelope. */ +export const listSmsBroadcastLists: ActiveCampaignEndpoints['smsBroadcastListsList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['smsBroadcastListsList'] + >('sms/lists', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ name: input.name }), + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.smsBroadcastLists.list', + listAuditPayload(input, ['limit', 'offset'], response.lists?.length ?? 0), + 'completed', + ); + return response; + }; + +export const removeAddressGroup: ActiveCampaignEndpoints['addressGroupsDelete'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `addressGroups/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.addressGroups.delete', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; + }; + +/** + * Finds one order by the identifiers the source system knows it by. + * + * ActiveCampaign has no route taking a store order id directly, so the + * collection is filtered on `externalid` within a connection, and an exact + * comparison decides - the filter is not guaranteed to be exact. + */ +export const findOrder: ActiveCampaignEndpoints['ecomOrdersFind'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest<{ + ecomOrders?: Array<{ externalid?: string }>; + }>('ecomOrders', ctx.key, account, { + method: 'GET', + query: compactQuery({ + 'filters[externalid]': input.storeOrderId, + 'filters[connectionid]': input.connectionId, + }), + }); + + const match = (response.ecomOrders ?? []).find( + (o) => o.externalid === input.storeOrderId, + ); + + await logEventFromContext( + ctx, + 'activecampaign.ecomOrders.find', + { + connectionId: input.connectionId, + matched: match !== undefined, + fields: ['storeOrderId'], + }, + 'completed', + ); + return { + ecomOrder: match ?? null, + } as ActiveCampaignEndpointOutputs['ecomOrdersFind']; +}; + +/** + * Creates an order, or updates the existing one with the same store order id + * within the connection. + * + * Lookup and write are separate requests, so concurrent calls for the same + * connectionid and externalid can both miss and POST duplicates. Callers must + * serialize those upserts, or use orders.upsertBulk which matches server-side. + * + * The lookup is a read, so a transport failure before the write is safe to + * replay; the write half is listed as non-idempotent. + */ +export const upsertOrder: ActiveCampaignEndpoints['ecomOrdersUpsert'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + + const found = await makeActiveCampaignRequest<{ + ecomOrders?: Array<{ id?: string; externalid?: string }>; + }>('ecomOrders', ctx.key, account, { + method: 'GET', + query: compactQuery({ + 'filters[externalid]': input.externalid, + 'filters[connectionid]': input.connectionid, + }), + }); + + const existing = (found.ecomOrders ?? []).find( + (o) => o.externalid === input.externalid, + ); + + const body = { ecomOrder: compactBody({ ...input }) }; + + const response = existing?.id + ? await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['ecomOrdersUpsert'] + >(`ecomOrders/${existing.id}`, ctx.key, account, { method: 'PUT', body }) + : await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['ecomOrdersUpsert'] + >('ecomOrders', ctx.key, account, { method: 'POST', body }); + + await logEventFromContext( + ctx, + 'activecampaign.ecomOrders.upsert', + { + connectionid: input.connectionid, + created: existing?.id === undefined, + fields: ['externalid', 'email'], + }, + 'completed', + ); + return response; +}; + +/** The product lines belonging to one order, filtered on the collection. */ +export const listProductsForOrder: ActiveCampaignEndpoints['ecomOrderProductsListForOrder'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['ecomOrderProductsListForOrder'] + >('ecomOrderProducts', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ 'filters[orderid]': input.orderId }), + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.ecomOrderProducts.listForOrder', + listAuditPayload( + input, + ['orderId', 'limit', 'offset'], + response.ecomOrderProducts?.length ?? 0, + ), + 'completed', + ); + return response; + }; + +/** + * Creates a reminder on a deal task. + * + * The route is `taskNotifications`, not the `taskReminders` the catalog name + * suggests - confirmed 200 against a live account on 2026-08-14. `interval` is + * minutes before the due date. + */ +export const createTaskReminder: ActiveCampaignEndpoints['taskRemindersCreate'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['taskRemindersCreate'] + >('taskNotifications', ctx.key, account, { + method: 'POST', + body: { + taskNotification: { + dealTask: input.dealTask, + interval: input.interval, + }, + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.taskReminders.create', + auditPayload(input, ['dealTask', 'interval']), + 'completed', + ); + return response; + }; + +/** + * Creates a child schema under a public parent schema. + * + * Posts to the same `customObjects/schemas` collection the other schema + * operations use - that route is confirmed - with the parent identifiers + * added. The child-specific body fields themselves are from the documentation + * rather than a captured request, since the development account has no public + * parent schema to create a child of. + */ +export const createChildSchema: ActiveCampaignEndpoints['customObjectSchemasCreateChild'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['customObjectSchemasCreateChild'] + >('customObjects/schemas', ctx.key, account, { + method: 'POST', + body: compactBody({ + parentId: input.parentId, + applicationId: input.applicationId, + slug: input.slug, + name: input.name, + description: input.description, + }), + }); + + await logEventFromContext( + ctx, + 'activecampaign.customObjectSchemas.createChild', + auditPayload(input, ['parentId', 'applicationId', 'slug']), + 'completed', + ); + return response; + }; + +/** + * Aggregate bulk-import progress across all batches. + * + * ROUTE UNVERIFIED: `import/bulk_import/aggregate` and every variant tried + * answered 404 on the development account, while `import/bulk_import` itself + * answers 200. The path below follows the documented shape; see + * UNVERIFIED_ROUTES in `segments-v2.ts` for the same caveat applied to the V2 + * segments surface. + */ +export const listImportAggregate: ActiveCampaignEndpoints['importsListAggregate'] = + async (ctx) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['importsListAggregate'] + >('import/bulk_import/aggregate', ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.imports.listAggregate', + {}, + 'completed', + ); + return response; + }; + +/** + * Simulates a tracking event through the browse-session system, returning the + * debug output that shows how a URL would be matched to a product. + * + * ROUTE UNVERIFIED: the mutation name follows the documented shape but could + * not be exercised - the development account has no e-commerce connection. + */ +export const testTrackingEvent: ActiveCampaignEndpoints['browseSessionsTestEvent'] = + async (ctx, input) => { + const data = await graphql< + ActiveCampaignEndpointOutputs['browseSessionsTestEvent'] + >( + ctx, + `mutation TestTrackingEvent($input: TestTrackingEventInput!) { + testTrackingEvent(input: $input) { matched debug } + }`, + { input: compactBody({ ...input }) }, + ); + + await logEventFromContext( + ctx, + 'activecampaign.browseSessions.testEvent', + auditPayload(input, ['connectionId']), + 'completed', + ); + return data; + }; diff --git a/packages/activecampaign/endpoints/resource.ts b/packages/activecampaign/endpoints/resource.ts new file mode 100644 index 000000000..941004bc4 --- /dev/null +++ b/packages/activecampaign/endpoints/resource.ts @@ -0,0 +1,278 @@ +import { logEventFromContext } from 'corsair/core'; +import type { z } from 'zod'; +import { makeActiveCampaignRequest } from '../client'; +import { auditPayload, listAuditPayload } from './logging'; +import { evictRow, persistRow, persistRows } from './persist'; +import { + buildPaginationQuery, + compactBody, + compactQuery, + resolveAccount, +} from './shared'; + +/** + * Builds the five standard operations for an ActiveCampaign REST resource. + * + * Nearly every resource on the v3 API follows one shape: a collection at + * `/` returning rows under a plural envelope key, a single record at + * `//{id}` under a singular key, and create/update taking a body wrapped + * in that same singular key. Rather than repeat that eleven times with only + * the strings changed, it is declared once here. + * + * Every rule the hand-written groups follow is preserved: + * + * - rows are validated against the entity schema before being cached, and a + * rejected row warns rather than failing the call + * - cache writes are best-effort; a mirror failure never fails the operation + * - an explicit DELETE evicts, a read never does + * - `undefined` is stripped from bodies and queries so that "leave this alone" + * is not serialised as "clear this" + * - audit payloads allow-list identifiers; everything else is logged by field + * name only + * + * The `ctx` and returned handlers are typed loosely here and re-typed at each + * export site against the operation's own key, so the per-operation input and + * output types are still enforced at the boundary. This is the only place in + * the plugin where that widening happens. + */ + +type ResourceCtx = { + key: string; + options: { account?: string }; + keys: { get_account: () => Promise }; + db: Record; +}; + +/** + * The generic handler shape. `input` is `Record` rather than + * a narrower type on purpose: each export site re-types the handler against + * its own operation key, and a narrower parameter here (`Record`, say) would make every one of those casts a non-overlapping + * conversion. + */ +type Handler = ( + ctx: ResourceCtx, + input: Record, +) => Promise; + +export interface ResourceConfig { + /** REST path segment, e.g. `dealGroups`. */ + path: string; + /** Envelope key on a single-record response, e.g. `dealGroup`. */ + one: string; + /** Envelope key on a collection response, e.g. `dealGroups`. */ + many: string; + /** Event name prefix, e.g. `activecampaign.dealGroups`. */ + event: string; + /** Entity schema used to validate rows before caching. Omit to skip. */ + entity?: z.ZodType; + /** Key in `ctx.db` to mirror into. Omit to skip mirroring. */ + store?: string; + /** Human label used in warnings, e.g. `dealGroup`. */ + label?: string; + /** + * Input keys that may be logged by value. Identifiers, pagination and + * status flags only - never names, emails, or free text. + */ + logKeys?: readonly string[]; + /** Extra query parameters passed through on list, beyond pagination. */ + queryKeys?: readonly string[]; + /** Maps caller-facing keys to ActiveCampaign's wire-level query keys. */ + queryMap?: Readonly>; + /** Body keys accepted by create and update. */ + bodyKeys?: readonly string[]; +} + +/** Delegates to the shared resolver so the raise-on-missing rule is uniform. */ +async function account(ctx: ResourceCtx): Promise { + return resolveAccount(ctx); +} + +function pick( + input: Record, + keys: readonly string[], +): Record { + const out: Record = {}; + for (const k of keys) out[k] = input[k]; + return out; +} + +export function mapQuery( + input: Record, + mapping: Readonly>, +): Record { + const out: Record = {}; + for (const [inputKey, queryKey] of Object.entries(mapping)) { + const value = input[inputKey]; + if ( + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + out[queryKey] = value; + } + } + return compactQuery(out); +} + +export function makeResource(config: ResourceConfig) { + const label = config.label ?? config.one; + const logKeys = config.logKeys ?? ['id', 'limit', 'offset']; + + const list: Handler = async (ctx, rawInput) => { + const input = rawInput as Record; + const response = (await makeActiveCampaignRequest( + config.path, + ctx.key, + await account(ctx), + { + method: 'GET', + query: { + ...buildPaginationQuery(input as { limit?: number; offset?: number }), + ...compactQuery( + pick(input, config.queryKeys ?? []) as Record, + ), + ...mapQuery(input, config.queryMap ?? {}), + }, + }, + )) as Record; + + const rows = response[config.many]; + if (config.entity && config.store) { + await persistRows( + ctx.db[config.store] as never, + config.entity, + rows, + label, + ); + } + + await logEventFromContext( + ctx as never, + `${config.event}.list`, + listAuditPayload(input, logKeys, Array.isArray(rows) ? rows.length : 0), + 'completed', + ); + return response; + }; + + const get: Handler = async (ctx, rawInput) => { + const input = rawInput as unknown as { id: string }; + const response = (await makeActiveCampaignRequest( + `${config.path}/${encodeURIComponent(input.id)}`, + ctx.key, + await account(ctx), + { method: 'GET' }, + )) as Record; + + if (config.entity && config.store) { + await persistRow( + ctx.db[config.store] as never, + config.entity, + response[config.one], + label, + ); + } + + await logEventFromContext( + ctx as never, + `${config.event}.get`, + auditPayload(input as never, ['id']), + 'completed', + ); + return response; + }; + + const create: Handler = async (ctx, rawInput) => { + const input = rawInput as Record; + const response = (await makeActiveCampaignRequest( + config.path, + ctx.key, + await account(ctx), + { + method: 'POST', + body: { [config.one]: compactBody(pick(input, config.bodyKeys ?? [])) }, + }, + )) as Record; + + if (config.entity && config.store) { + await persistRow( + ctx.db[config.store] as never, + config.entity, + response[config.one], + label, + ); + } + + await logEventFromContext( + ctx as never, + `${config.event}.create`, + auditPayload(input, logKeys), + 'completed', + ); + return response; + }; + + const update: Handler = async (ctx, rawInput) => { + const input = rawInput as Record; + const id = input.id; + if (typeof id !== 'string' || id.length === 0) { + throw new Error('An id is required to update this resource'); + } + const response = (await makeActiveCampaignRequest( + `${config.path}/${encodeURIComponent(id)}`, + ctx.key, + await account(ctx), + { + method: 'PUT', + body: { [config.one]: compactBody(pick(input, config.bodyKeys ?? [])) }, + }, + )) as Record; + + if (config.entity && config.store) { + await persistRow( + ctx.db[config.store] as never, + config.entity, + response[config.one], + label, + ); + } + + await logEventFromContext( + ctx as never, + `${config.event}.update`, + auditPayload(input, logKeys), + 'completed', + ); + return response; + }; + + /** + * An explicit DELETE is permanent, so the mirrored row is evicted. Reads + * never evict - ActiveCampaign archives far more often than it deletes. + */ + const remove: Handler = async (ctx, rawInput) => { + const input = rawInput as unknown as { id: string }; + await makeActiveCampaignRequest( + `${config.path}/${encodeURIComponent(input.id)}`, + ctx.key, + await account(ctx), + { method: 'DELETE' }, + ); + + if (config.store) { + await evictRow(ctx.db[config.store] as never, input.id, label); + } + + await logEventFromContext( + ctx as never, + `${config.event}.delete`, + auditPayload(input as never, ['id']), + 'completed', + ); + return { id: input.id }; + }; + + return { list, get, create, update, remove }; +} diff --git a/packages/activecampaign/endpoints/segments-v2.ts b/packages/activecampaign/endpoints/segments-v2.ts new file mode 100644 index 000000000..b3580c696 --- /dev/null +++ b/packages/activecampaign/endpoints/segments-v2.ts @@ -0,0 +1,356 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { buildPaginationQuery, resolveAccount } from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +/** + * The V2 segments API: segment definitions keyed by UUID, their count history, + * and contact match evaluation. + * + * ROUTE VERIFICATION STATUS - read before changing anything here. + * + * Every other operation in this plugin was confirmed against a live account + * before being written. These could not be: the account used for development + * answers 404 to `/v2/segments`, `/segments/v2`, `/segments/{id}/counts`, + * `/segments/{id}/match` and `/api/v2/segments`, and the ActiveCampaign + * documentation pages for the V2 segments API were not reachable either. The + * legacy `/segments` collection - which this plugin does implement, in + * `content.ts` - answers 200 on the same account, so the V2 surface appears to + * be gated by plan or by feature flag rather than simply misnamed. + * + * The paths below therefore follow the shape the OSS catalog descriptions + * imply, and are listed in {@link UNVERIFIED_ROUTES} so the uncertainty is + * visible in code rather than buried in a commit message. `segments.test.ts` + * asserts that list stays in step with this file. + * + * Before relying on these: run them against an account that has the V2 + * segments feature, correct the paths against what actually answers, and + * remove the entries from UNVERIFIED_ROUTES. + */ + +/** + * Operations whose route could not be confirmed against a live account. + * + * Kept as an exported constant so the PR body, the docs and the tests all read + * the same list, and so a reviewer can see the honest state at a glance. + */ +export const UNVERIFIED_ROUTES: ReadonlySet = new Set([ + 'segmentsV2Create', + 'segmentsV2Get', + 'segmentsV2Update', + 'segmentsV2Delete', + 'segmentsV2GetAtTimestamp', + 'segmentsV2RevertToTimestamp', + 'segmentsV2RecentCounts', + 'segmentsV2CountHistory', + 'segmentsV2CountAtTimestamp', + 'segmentsV2Match', + 'segmentsV2MatchByExternalId', + 'segmentsV2MatchAll', + 'segmentsV2MatchAllResult', + 'segmentsV2MatchSomeResult', +]); + +const BASE = 'segments'; + +export const create: ActiveCampaignEndpoints['segmentsV2Create'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2Create'] + >(BASE, ctx.key, account, { + method: 'POST', + body: { + segment: { + name: input.name, + ...(input.description !== undefined && { + description: input.description, + }), + ...(input.conditions !== undefined && { conditions: input.conditions }), + }, + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.create', + auditPayload(input, []), + 'completed', + ); + return response; +}; + +export const get: ActiveCampaignEndpoints['segmentsV2Get'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2Get'] + >(`${BASE}/${input.id}`, ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.get', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +export const update: ActiveCampaignEndpoints['segmentsV2Update'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2Update'] + >(`${BASE}/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { + segment: { + ...(input.name !== undefined && { name: input.name }), + ...(input.description !== undefined && { + description: input.description, + }), + ...(input.conditions !== undefined && { conditions: input.conditions }), + }, + }, + }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.update', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +/** + * Deleting a segment removes every historic version of it as well, so the API + * returns the segment's final state as an audit trail. + */ +export const remove: ActiveCampaignEndpoints['segmentsV2Delete'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2Delete'] + >(`${BASE}/${input.id}`, ctx.key, account, { method: 'DELETE' }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.delete', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +/** The segment definition as it stood at a point in time. */ +export const getAtTimestamp: ActiveCampaignEndpoints['segmentsV2GetAtTimestamp'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2GetAtTimestamp'] + >(`${BASE}/${input.id}/${input.timestamp}`, ctx.key, account, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.getAtTimestamp', + auditPayload(input, ['id', 'timestamp']), + 'completed', + ); + return response; + }; + +/** Restores a segment to how it looked at a point in time. */ +export const revertToTimestamp: ActiveCampaignEndpoints['segmentsV2RevertToTimestamp'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2RevertToTimestamp'] + >(`${BASE}/${input.id}/${input.timestamp}`, ctx.key, account, { + method: 'PUT', + }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.revertToTimestamp', + auditPayload(input, ['id', 'timestamp']), + 'completed', + ); + return response; + }; + +export const recentCounts: ActiveCampaignEndpoints['segmentsV2RecentCounts'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2RecentCounts'] + >(`${BASE}/counts`, ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.recentCounts', + auditPayload(input, ['limit', 'offset']), + 'completed', + ); + return response; + }; + +/** + * Historic counts for one segment. ActiveCampaign documents a cap of 50 + * results and 90 days of retention. + */ +export const countHistory: ActiveCampaignEndpoints['segmentsV2CountHistory'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2CountHistory'] + >(`${BASE}/${input.id}/counts`, ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.countHistory', + auditPayload(input, ['id']), + 'completed', + ); + return response; + }; + +export const countAtTimestamp: ActiveCampaignEndpoints['segmentsV2CountAtTimestamp'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2CountAtTimestamp'] + >(`${BASE}/${input.id}/counts/${input.timestamp}`, ctx.key, account, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.countAtTimestamp', + auditPayload(input, ['id', 'timestamp']), + 'completed', + ); + return response; + }; + +/** Whether one contact matches a segment. */ +export const match: ActiveCampaignEndpoints['segmentsV2Match'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2Match'] + >(`${BASE}/${input.id}/match/${input.contactId}`, ctx.key, account, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.match', + auditPayload(input, ['id', 'contactId']), + 'completed', + ); + return response; +}; + +export const matchByExternalId: ActiveCampaignEndpoints['segmentsV2MatchByExternalId'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2MatchByExternalId'] + >( + `${BASE}/${input.id}/match/external/${encodeURIComponent(input.externalId)}`, + ctx.key, + account, + { method: 'GET' }, + ); + + // The external id is the caller's own key for a person; only the field + // name is recorded. + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.matchByExternalId', + { id: input.id, fields: ['externalId'] }, + 'completed', + ); + return response; + }; + +/** + * Starts a match-all evaluation. + * + * ActiveCampaign answers within about four seconds if it can, and otherwise + * returns `is_ready: false` with a run id to poll - which is what + * `matchAllResult` is for. + */ +export const matchAll: ActiveCampaignEndpoints['segmentsV2MatchAll'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2MatchAll'] + >(`${BASE}/${input.id}/matchAll`, ctx.key, account, { method: 'POST' }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.matchAll', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +/** + * Fetches a match-all result set by run id. + * + * `is_ready: false` with `run_id_end` populated means the run errored rather + * than that it is still working - worth checking both before polling again. + */ +export const matchAllResult: ActiveCampaignEndpoints['segmentsV2MatchAllResult'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2MatchAllResult'] + >(`${BASE}/matchAll/${input.runId}`, ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.matchAllResult', + auditPayload(input, ['runId']), + 'completed', + ); + return response; + }; + +export const matchSomeResult: ActiveCampaignEndpoints['segmentsV2MatchSomeResult'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['segmentsV2MatchSomeResult'] + >(`${BASE}/matchSome/${input.runId}`, ctx.key, account, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'activecampaign.segmentsV2.matchSomeResult', + auditPayload(input, ['runId']), + 'completed', + ); + return response; + }; diff --git a/packages/activecampaign/endpoints/shared.ts b/packages/activecampaign/endpoints/shared.ts new file mode 100644 index 000000000..fdbf42cab --- /dev/null +++ b/packages/activecampaign/endpoints/shared.ts @@ -0,0 +1,95 @@ +import { AuthMissingError } from 'corsair/core'; + +/** + * Strips keys whose value is `undefined`. + * + * ActiveCampaign distinguishes an absent field from an explicit `null`: + * omitting a field leaves the stored value alone, while sending `null` clears + * it. `JSON.stringify` drops `undefined` from objects but not from the shape + * callers build up conditionally, so compacting here keeps "leave this alone" + * from being serialised as "clear this". + */ +export function compactBody( + body: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (value !== undefined) { + out[key] = value; + } + } + return out; +} + +/** + * Same rule as {@link compactBody}, for query strings. A `undefined` query + * value would otherwise be serialised as the literal string "undefined". + */ +export function compactQuery( + query: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + out[key] = value; + } + } + return out; +} + +/** + * ActiveCampaign's REST collections share one pagination contract: `limit` and + * `offset` query parameters, the rows under a resource-named key, and the + * total under `meta.total`. Declared once here and reused by every list + * operation so the envelope cannot drift between resources. + * + * The default page size is 20 and the documented maximum is 100; a caller + * asking for more than 100 would be silently capped by the API, so the limit + * is clamped here where it is visible instead. + * + * @see https://developers.activecampaign.com/reference/pagination + */ +export const AC_PAGE_SIZE_DEFAULT = 20; +export const AC_PAGE_SIZE_MAX = 100; + +export function buildPaginationQuery(input: { + limit?: number; + offset?: number; +}): Record { + const limit = + input.limit === undefined + ? undefined + : Math.min(Math.max(input.limit, 1), AC_PAGE_SIZE_MAX); + return compactQuery({ limit, offset: input.offset }); +} + +/** + * Resolves the account slug - the second half of the ActiveCampaign + * credential. + * + * Declared once here rather than per endpoint file. It raises rather than + * returning an empty string, because an empty slug would otherwise be + * interpolated into the base URL and the failure would surface as a confusing + * transport error against `https://.api-us1.com` instead of as the missing + * credential it actually is. + * + * `AuthMissingError` is the core's own signal for this, so the runtime can + * tell a configuration gap apart from an API failure. + */ +export async function resolveAccount(ctx: { + options?: { account?: string }; + keys?: { get_account?: () => Promise }; +}): Promise { + const account = + ctx.options?.account ?? (await ctx.keys?.get_account?.()) ?? ''; + + if (!account) { + throw new AuthMissingError( + 'activecampaign', + 'account', + '[auth-missing:activecampaign:account]: an ActiveCampaign account slug is required - it is the subdomain of your API URL, https://.api-us1.com', + ); + } + + return account; +} diff --git a/packages/activecampaign/endpoints/tags.ts b/packages/activecampaign/endpoints/tags.ts new file mode 100644 index 000000000..953a57d6c --- /dev/null +++ b/packages/activecampaign/endpoints/tags.ts @@ -0,0 +1,228 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeActiveCampaignRequest } from '../client'; +import type { ActiveCampaignEndpoints } from '../index'; +import { + ActiveCampaignContactTag, + ActiveCampaignTag, +} from '../schema/database'; +import { auditPayload, listAuditPayload } from './logging'; +import { evictChildren, evictRow, persistRow, persistRows } from './persist'; +import { + buildPaginationQuery, + compactBody, + compactQuery, + resolveAccount, +} from './shared'; +import type { ActiveCampaignEndpointOutputs } from './types'; + +export const list: ActiveCampaignEndpoints['tagsList'] = async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['tagsList'] + >('tags', ctx.key, account, { + method: 'GET', + query: { + ...buildPaginationQuery(input), + ...compactQuery({ 'filters[search][contains]': input.search }), + }, + }); + + await persistRows(ctx.db.tags, ActiveCampaignTag, response.tags, 'tag'); + + await logEventFromContext( + ctx, + 'activecampaign.tags.list', + listAuditPayload(input, ['limit', 'offset'], response.tags?.length ?? 0), + 'completed', + ); + return response; +}; + +export const get: ActiveCampaignEndpoints['tagsGet'] = async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['tagsGet'] + >(`tags/${input.id}`, ctx.key, account, { method: 'GET' }); + + await persistRow(ctx.db.tags, ActiveCampaignTag, response.tag, 'tag'); + + await logEventFromContext( + ctx, + 'activecampaign.tags.get', + auditPayload(input, ['id']), + 'completed', + ); + return response; +}; + +export const create: ActiveCampaignEndpoints['tagsCreate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['tagsCreate'] + >('tags', ctx.key, account, { + method: 'POST', + body: { + tag: compactBody({ + tag: input.tag, + tagType: input.tagType, + description: input.description, + }), + }, + }); + + await persistRow(ctx.db.tags, ActiveCampaignTag, response.tag, 'tag'); + + await logEventFromContext( + ctx, + 'activecampaign.tags.create', + auditPayload(input, ['tag', 'tagType']), + 'completed', + ); + return response; +}; + +export const update: ActiveCampaignEndpoints['tagsUpdate'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['tagsUpdate'] + >(`tags/${input.id}`, ctx.key, account, { + method: 'PUT', + body: { + tag: compactBody({ + tag: input.tag, + tagType: input.tagType, + description: input.description, + }), + }, + }); + + await persistRow(ctx.db.tags, ActiveCampaignTag, response.tag, 'tag'); + + await logEventFromContext( + ctx, + 'activecampaign.tags.update', + auditPayload(input, ['id', 'tag', 'tagType']), + 'completed', + ); + return response; +}; + +export const remove: ActiveCampaignEndpoints['tagsDelete'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `tags/${input.id}`, + ctx.key, + account, + { + method: 'DELETE', + }, + ); + + await evictRow(ctx.db.tags, input.id, 'tag'); + // Deleting a tag removes it from every contact upstream, so the cached + // associations go with it. + await evictChildren(ctx.db.contactTags, 'tag', input.id, 'contactTag'); + + await logEventFromContext( + ctx, + 'activecampaign.tags.delete', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; +}; + +export const addToContact: ActiveCampaignEndpoints['tagsAddToContact'] = async ( + ctx, + input, +) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['tagsAddToContact'] + >('contactTags', ctx.key, account, { + method: 'POST', + body: { contactTag: { contact: input.contact, tag: input.tag } }, + }); + + await persistRow( + ctx.db.contactTags, + ActiveCampaignContactTag, + response.contactTag, + 'contactTag', + ); + + await logEventFromContext( + ctx, + 'activecampaign.tags.addToContact', + auditPayload(input, ['contact', 'tag']), + 'completed', + ); + return response; +}; + +/** + * Removes a tag from a contact. + * + * The id is the contactTag association id, not the tag id - deleting by tag id + * would delete the tag itself for every contact. Only the association row is + * evicted; the tag stays in the mirror because it still exists upstream. + */ +export const removeFromContact: ActiveCampaignEndpoints['tagsRemoveFromContact'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + await makeActiveCampaignRequest( + `contactTags/${input.id}`, + ctx.key, + account, + { method: 'DELETE' }, + ); + + await evictRow(ctx.db.contactTags, input.id, 'contactTag'); + + await logEventFromContext( + ctx, + 'activecampaign.tags.removeFromContact', + auditPayload(input, ['id']), + 'completed', + ); + return { id: input.id }; + }; + +export const listContactTags: ActiveCampaignEndpoints['contactTagsList'] = + async (ctx, input) => { + const account = await resolveAccount(ctx); + const response = await makeActiveCampaignRequest< + ActiveCampaignEndpointOutputs['contactTagsList'] + >('contactTags', ctx.key, account, { + method: 'GET', + query: buildPaginationQuery(input), + }); + + await persistRows( + ctx.db.contactTags, + ActiveCampaignContactTag, + response.contactTags, + 'contactTag', + ); + + await logEventFromContext( + ctx, + 'activecampaign.contactTags.list', + listAuditPayload( + input, + ['limit', 'offset'], + response.contactTags?.length ?? 0, + ), + 'completed', + ); + return response; + }; diff --git a/packages/activecampaign/endpoints/types.ts b/packages/activecampaign/endpoints/types.ts new file mode 100644 index 000000000..b5a3a6afa --- /dev/null +++ b/packages/activecampaign/endpoints/types.ts @@ -0,0 +1,2708 @@ +import { z } from 'zod'; +import { + ActiveCampaignAccount, + ActiveCampaignAccountContact, + ActiveCampaignAccountCustomFieldMeta, + ActiveCampaignAddress, + ActiveCampaignAutomation, + ActiveCampaignBranding, + ActiveCampaignCalendar, + ActiveCampaignCampaign, + ActiveCampaignConnection, + ActiveCampaignContact, + ActiveCampaignContactList, + ActiveCampaignContactTag, + ActiveCampaignCustomObjectSchema, + ActiveCampaignDeal, + ActiveCampaignDealCustomFieldMeta, + ActiveCampaignDealGroup, + ActiveCampaignDealRole, + ActiveCampaignDealStage, + ActiveCampaignDealTask, + ActiveCampaignDealTaskType, + ActiveCampaignEcomCustomer, + ActiveCampaignEventTrackingEvent, + ActiveCampaignField, + ActiveCampaignFieldOption, + ActiveCampaignFieldRel, + ActiveCampaignFieldValue, + ActiveCampaignForm, + ActiveCampaignGroup, + ActiveCampaignGroupLimit, + ActiveCampaignGroupMember, + ActiveCampaignList, + ActiveCampaignListGroup, + ActiveCampaignMessage, + ActiveCampaignNote, + ActiveCampaignPersonalization, + ActiveCampaignSavedResponse, + ActiveCampaignScore, + ActiveCampaignSegment, + ActiveCampaignTag, + ActiveCampaignTaskOutcome, + ActiveCampaignTemplate, + ActiveCampaignUser, + ActiveCampaignWebhook, +} from '../schema/database'; + +/** + * Input and output schemas, one pair per operation. + * + * Outputs reuse the entity definitions from `schema/database.ts` so the + * persisted shape and the returned shape cannot drift apart. + * + * Inputs are enumerated from ActiveCampaign's documented request-parameter + * tables. They cannot be derived from captured responses - a response says + * nothing about which parameters an endpoint accepts. + */ + +/** + * Every REST collection shares this envelope: rows under a resource-named key + * and the count under `meta.total`. `total` is a string, like every other + * scalar ActiveCampaign returns. + * + * @see https://developers.activecampaign.com/reference/pagination + */ +const Meta = z + .object({ total: z.union([z.string(), z.number()]).nullable().optional() }) + .loose() + .nullable() + .optional(); + +/** + * Pagination accepted by every list operation. ActiveCampaign defaults to 20 + * rows and caps at 100. + */ +const PaginationInput = { + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().min(0).optional(), +}; + +// --------------------------------------------------------------------------- +// Contacts +// --------------------------------------------------------------------------- + +export const ContactsListInput = z.object({ + ...PaginationInput, + email: z.email().optional(), + search: z.string().optional(), + listid: z.string().optional(), + tagid: z.string().optional(), + segmentid: z.string().optional(), + status: z.number().int().optional(), + /** + * ActiveCampaign documents `id_greater` with `orders[id]=ASC` as the + * performant way to page a large contact collection, because `offset` + * degrades on big lists. Exposed so callers can opt into it. + */ + id_greater: z.string().optional(), + orders_id: z.enum(['ASC', 'DESC']).optional(), +}); + +export const ContactsListOutput = z + .object({ contacts: z.array(ActiveCampaignContact), meta: Meta }) + .loose(); + +export const ContactsGetInput = z.object({ + id: z.string(), + automations: z.boolean().optional(), +}); + +/** + * A single-contact GET sideloads related collections alongside the contact. + * They are modelled as unknown because their shapes belong to resources this + * PR does not implement; typing them from an unverified guess would be worse + * than declaring them unmodelled. + */ +export const ContactsGetOutput = z + .object({ + contact: ActiveCampaignContact, + contactLists: z.unknown().optional(), + fieldValues: z.unknown().optional(), + geoIps: z.unknown().optional(), + deals: z.unknown().optional(), + accountContacts: z.unknown().optional(), + }) + .loose(); + +export const ContactsFindInput = z.object({ email: z.email() }); +export const ContactsFindOutput = ContactsListOutput; + +export const ContactsCreateOrUpdateInput = z.object({ + email: z.email(), + firstName: z.string().optional(), + lastName: z.string().optional(), + phone: z.string().optional(), + fieldValues: z + .array(z.object({ field: z.string(), value: z.string() })) + .optional(), +}); + +export const ContactsCreateOrUpdateOutput = z + .object({ contact: ActiveCampaignContact }) + .loose(); + +export const ContactsUpdateInput = z.object({ + id: z.string(), + email: z.email().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + phone: z.string().optional(), + fieldValues: z + .array(z.object({ field: z.string(), value: z.string() })) + .optional(), +}); + +export const ContactsUpdateOutput = ContactsCreateOrUpdateOutput; + +export const ContactsDeleteInput = z.object({ id: z.string() }); +export const ContactsDeleteOutput = z.object({ id: z.string() }).loose(); + +const ContactSubResourceInput = z.object({ + id: z.string(), + ...PaginationInput, +}); + +export const ContactsGetListsInput = ContactSubResourceInput; +export const ContactsGetListsOutput = z + .object({ contactLists: z.array(ActiveCampaignContactList), meta: Meta }) + .loose(); + +export const ContactsGetTagsInput = ContactSubResourceInput; +export const ContactsGetTagsOutput = z + .object({ contactTags: z.array(ActiveCampaignContactTag), meta: Meta }) + .loose(); + +export const ContactsGetFieldValuesInput = ContactSubResourceInput; +/** Field-value rows belong to a resource group outside this PR's scope. */ +export const ContactsGetFieldValuesOutput = z + .object({ fieldValues: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const ContactsGetAutomationsInput = ContactSubResourceInput; +export const ContactsGetAutomationsOutput = z + .object({ contactAutomations: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const ContactsGetGeoIpsInput = ContactSubResourceInput; +export const ContactsGetGeoIpsOutput = z + .object({ geoIps: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const ContactsGetScoreValuesInput = ContactSubResourceInput; +export const ContactsGetScoreValuesOutput = z + .object({ scoreValues: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const ContactsGetDealsInput = ContactSubResourceInput; +export const ContactsGetDealsOutput = z + .object({ deals: z.array(z.unknown()), meta: Meta }) + .loose(); + +// --------------------------------------------------------------------------- +// Lists +// --------------------------------------------------------------------------- + +export const ListsListInput = z.object({ + ...PaginationInput, + name: z.string().optional(), +}); + +export const ListsListOutput = z + .object({ lists: z.array(ActiveCampaignList), meta: Meta }) + .loose(); + +export const ListsGetInput = z.object({ id: z.string() }); +export const ListsGetOutput = z.object({ list: ActiveCampaignList }).loose(); + +export const ListsCreateInput = z.object({ + name: z.string().min(1), + /** URL-safe identifier ActiveCampaign requires alongside the display name. */ + stringid: z.string().min(1), + sender_url: z.string().min(1), + sender_reminder: z.string().min(1), + send_last_broadcast: z.boolean().optional(), + carboncopy: z.string().optional(), + subscription_notify: z.string().optional(), + unsubscription_notify: z.string().optional(), + user: z.string().optional(), +}); + +export const ListsCreateOutput = ListsGetOutput; + +export const ListsDeleteInput = z.object({ id: z.string() }); +export const ListsDeleteOutput = z.object({ id: z.string() }).loose(); + +export const ContactListsListInput = z.object({ ...PaginationInput }); +export const ContactListsListOutput = ContactsGetListsOutput; + +/** + * Subscribe or unsubscribe a contact. + * + * ActiveCampaign models both directions as a status on the contact-list + * association: 1 subscribes, 2 unsubscribes. The association row survives an + * unsubscribe, which is what preserves the audit trail. + */ +export const ListsUpdateSubscriptionInput = z.object({ + list: z.string(), + contact: z.string(), + status: z.union([z.literal(1), z.literal(2)]), +}); + +export const ListsUpdateSubscriptionOutput = z + .object({ contactList: ActiveCampaignContactList }) + .loose(); + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- + +export const TagsListInput = z.object({ + ...PaginationInput, + search: z.string().optional(), +}); + +export const TagsListOutput = z + .object({ tags: z.array(ActiveCampaignTag), meta: Meta }) + .loose(); + +export const TagsGetInput = z.object({ id: z.string() }); +export const TagsGetOutput = z.object({ tag: ActiveCampaignTag }).loose(); + +export const TagsCreateInput = z.object({ + tag: z.string().min(1), + tagType: z.enum(['contact', 'template']), + description: z.string().optional(), +}); + +export const TagsCreateOutput = TagsGetOutput; + +export const TagsUpdateInput = z.object({ + id: z.string(), + tag: z.string().min(1).optional(), + tagType: z.enum(['contact', 'template']).optional(), + description: z.string().optional(), +}); + +export const TagsUpdateOutput = TagsGetOutput; + +export const TagsDeleteInput = z.object({ id: z.string() }); +export const TagsDeleteOutput = z.object({ id: z.string() }).loose(); + +export const TagsAddToContactInput = z.object({ + contact: z.string(), + tag: z.string(), +}); + +export const TagsAddToContactOutput = z + .object({ contactTag: ActiveCampaignContactTag }) + .loose(); + +/** + * Takes the contactTag association id, not the tag id - deleting by tag id + * would remove the tag itself. + */ +export const TagsRemoveFromContactInput = z.object({ id: z.string() }); +export const TagsRemoveFromContactOutput = z.object({ id: z.string() }).loose(); + +export const ContactTagsListInput = z.object({ ...PaginationInput }); +export const ContactTagsListOutput = ContactsGetTagsOutput; + +// --------------------------------------------------------------------------- +// Custom field definitions +// --------------------------------------------------------------------------- + +export const FieldsListInput = z.object({ ...PaginationInput }); +export const FieldsListOutput = z + .object({ fields: z.array(ActiveCampaignField), meta: Meta }) + .loose(); + +export const FieldsGetInput = z.object({ id: z.string() }); +export const FieldsGetOutput = z.object({ field: ActiveCampaignField }).loose(); + +// --------------------------------------------------------------------------- +// Custom field definitions: create, update, delete +// --------------------------------------------------------------------------- + +/** + * ActiveCampaign's custom field types. `dropdown`, `listbox`, `radio` and + * `checkbox` are the four that accept options; the others do not. + */ +const FieldType = z.enum([ + 'text', + 'textarea', + 'date', + 'multiselect', + 'number', + 'datetime', + 'dropdown', + 'listbox', + 'radio', + 'checkbox', + 'hidden', +]); + +export const FieldsCreateInput = z.object({ + title: z.string().min(1), + type: FieldType, + descript: z.string().optional(), + /** Personalisation tag, e.g. %INDUSTRY%. Generated when omitted. */ + perstag: z.string().optional(), + defval: z.string().optional(), + isrequired: z.boolean().optional(), + visible: z.boolean().optional(), + ordernum: z.number().int().optional(), +}); + +export const FieldsCreateOutput = FieldsGetOutput; + +export const FieldsUpdateInput = z.object({ + id: z.string(), + title: z.string().min(1).optional(), + type: FieldType.optional(), + descript: z.string().optional(), + perstag: z.string().optional(), + defval: z.string().optional(), + isrequired: z.boolean().optional(), + visible: z.boolean().optional(), + ordernum: z.number().int().optional(), +}); + +export const FieldsUpdateOutput = FieldsGetOutput; + +export const FieldsDeleteInput = z.object({ id: z.string() }); +export const FieldsDeleteOutput = z.object({ id: z.string() }).loose(); + +/** + * Options are created in bulk against an existing field. The field must exist + * first, and only the four option-bearing field types accept them. + */ +export const FieldOptionsCreateBulkInput = z.object({ + options: z + .array( + z.object({ + field: z.string(), + label: z.string().min(1), + value: z.string(), + orderid: z.number().int().optional(), + isdefault: z.boolean().optional(), + }), + ) + .min(1), +}); + +export const FieldOptionsCreateBulkOutput = z + .object({ fieldOptions: z.array(ActiveCampaignFieldOption) }) + .loose(); + +// --------------------------------------------------------------------------- +// Custom field values +// --------------------------------------------------------------------------- + +export const FieldValuesListInput = z.object({ ...PaginationInput }); +export const FieldValuesListOutput = z + .object({ fieldValues: z.array(ActiveCampaignFieldValue), meta: Meta }) + .loose(); + +export const FieldValuesGetInput = z.object({ id: z.string() }); +export const FieldValuesGetOutput = z + .object({ fieldValue: ActiveCampaignFieldValue }) + .loose(); + +/** + * Sets a field value on a contact. + * + * `useDefaults` asks ActiveCampaign to apply the field's configured default + * when the value is blank. It is sent explicitly rather than omitted so the + * behaviour is the caller's decision rather than inherited from the provider. + */ +export const FieldValuesSetForContactInput = z.object({ + contact: z.string(), + field: z.string(), + value: z.string(), + useDefaults: z.boolean().optional(), +}); + +export const FieldValuesSetForContactOutput = FieldValuesGetOutput; + +export const FieldValuesUpdateInput = z.object({ + id: z.string(), + value: z.string(), + useDefaults: z.boolean().optional(), +}); + +export const FieldValuesUpdateOutput = FieldValuesGetOutput; + +export const FieldValuesDeleteInput = z.object({ id: z.string() }); +export const FieldValuesDeleteOutput = z.object({ id: z.string() }).loose(); + +// --------------------------------------------------------------------------- +// Field relationships (field <-> list) +// --------------------------------------------------------------------------- + +export const FieldRelsListInput = z.object({ ...PaginationInput }); +export const FieldRelsListOutput = z + .object({ fieldRels: z.array(ActiveCampaignFieldRel), meta: Meta }) + .loose(); + +export const FieldRelsCreateInput = z.object({ + field: z.string(), + /** The related list id. `0` associates the field with every list. */ + relid: z.string(), +}); + +export const FieldRelsCreateOutput = z + .object({ fieldRel: ActiveCampaignFieldRel }) + .loose(); + +export const FieldRelsDeleteInput = z.object({ id: z.string() }); +export const FieldRelsDeleteOutput = z.object({ id: z.string() }).loose(); + +// --------------------------------------------------------------------------- +// Field groups (field <-> display group) +// --------------------------------------------------------------------------- + +export const GroupMembersListInput = z.object({ ...PaginationInput }); +export const GroupMembersListOutput = z + .object({ groupMembers: z.array(ActiveCampaignGroupMember), meta: Meta }) + .loose(); + +export const GroupMembersCreateInput = z.object({ + /** The field relationship id, not the field id. */ + rel_id: z.string(), + group_id: z.string(), + ordernum: z.number().int().optional(), +}); + +export const GroupMembersCreateOutput = z + .object({ groupMember: ActiveCampaignGroupMember }) + .loose(); + +export const GroupMembersUpdateInput = z.object({ + id: z.string(), + rel_id: z.string().optional(), + group_id: z.string().optional(), + ordernum: z.number().int().optional(), +}); + +export const GroupMembersUpdateOutput = GroupMembersCreateOutput; + +export const GroupMembersDeleteInput = z.object({ id: z.string() }); +export const GroupMembersDeleteOutput = z.object({ id: z.string() }).loose(); + +// --------------------------------------------------------------------------- +// Contact sub-resources +// +// Every route below was confirmed against a live account on 2026-08-13, but +// the trial account held no rows for any of them, so the row shapes could not +// be captured. They are typed `z.unknown()` deliberately: the envelope key is +// verified, the row shape is not, and inventing one from the documentation is +// exactly the mistake that required a maintainer hand-fix on a previous +// integration. They will be typed when a populated account is available. +// --------------------------------------------------------------------------- + +const ContactSubInput = z.object({ id: z.string(), ...PaginationInput }); + +export const ContactsGetLogsInput = ContactSubInput; +export const ContactsGetLogsOutput = z + .object({ contactLogs: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const ContactsGetTrackingLogsInput = ContactSubInput; +export const ContactsGetTrackingLogsOutput = z + .object({ trackingLogs: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const ContactsGetGoalsInput = ContactSubInput; +export const ContactsGetGoalsOutput = z + .object({ contactGoals: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const ContactsGetAccountContactsInput = ContactSubInput; +export const ContactsGetAccountContactsOutput = z + .object({ accountContacts: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const ContactsGetNotesInput = ContactSubInput; +export const ContactsGetNotesOutput = z + .object({ notes: z.array(z.unknown()), meta: Meta }) + .loose(); + +/** + * These three answer with a bare `{}` when the contact has no such record, + * rather than an envelope with an empty value, so even the envelope key is + * unconfirmed. Modelled as fully loose objects with optional keys so either + * response parses. + */ +export const ContactsGetDataInput = z.object({ id: z.string() }); +export const ContactsGetDataOutput = z + .object({ contactDatum: z.unknown().optional() }) + .loose(); + +export const ContactsGetOrganizationInput = z.object({ id: z.string() }); +export const ContactsGetOrganizationOutput = z + .object({ organization: z.unknown().optional() }) + .loose(); + +export const ContactsGetPlusAppendInput = z.object({ id: z.string() }); +export const ContactsGetPlusAppendOutput = z + .object({ plusAppend: z.unknown().optional() }) + .loose(); + +// --------------------------------------------------------------------------- +// Activities +// --------------------------------------------------------------------------- + +export const ActivitiesListInput = z.object({ + ...PaginationInput, + /** Restrict to one contact. Omitted returns account-wide activity. */ + contact: z.string().optional(), + after: z.string().optional(), +}); + +export const ActivitiesListOutput = z + .object({ activities: z.array(z.unknown()), meta: Meta }) + .loose(); + +// --------------------------------------------------------------------------- +// Bulk contact import +// --------------------------------------------------------------------------- + +/** + * Queues contacts for asynchronous import. ActiveCampaign accepts up to + * 250 per call with a payload below 400 KB and returns immediately with a + * batch id; the rows are processed in the background. + */ +export const ImportsCreateBulkInput = z.object({ + contacts: z + .array( + z.object({ + email: z.email(), + first_name: z.string().optional(), + last_name: z.string().optional(), + phone: z.string().optional(), + customer_acct_name: z.string().optional(), + tags: z.array(z.string()).optional(), + fields: z + .array(z.object({ id: z.number().int(), value: z.string() })) + .optional(), + subscribe: z.array(z.object({ listid: z.number().int() })).optional(), + unsubscribe: z.array(z.object({ listid: z.number().int() })).optional(), + }), + ) + .min(1) + .max(250), + exclude_automations: z.boolean().optional(), + /** + * Webhook called once the batch finishes. Optional; a batch can also be + * polled with `imports.getStatus`. + */ + callback: z + .object({ + url: z.url(), + requestType: z.enum(['GET', 'POST', 'PUT', 'PATCH']).optional(), + detailed_results: z + .union([z.boolean(), z.enum(['true', 'false'])]) + .optional(), + params: z.record(z.string(), z.unknown()).optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .optional(), +}); + +export const ImportsCreateBulkOutput = z + .object({ + Success: z.union([z.string(), z.number()]).nullable().optional(), + success: z + .union([z.string(), z.number(), z.boolean()]) + .nullable() + .optional(), + batchId: z.string().nullable().optional(), + queued_contacts: z.number().nullable().optional(), + queuedContacts: z.number().nullable().optional(), + message: z.string().nullable().optional(), + }) + .loose(); + +export const ImportsListInput = z.object({}); +export const ImportsListOutput = z + .object({ + outstanding: z.array(z.unknown()).optional(), + recentlyCompleted: z.array(z.unknown()).optional(), + }) + .loose(); + +export const ImportsGetStatusInput = z.object({ batchId: z.string() }); +export const ImportsGetStatusOutput = z + .object({ + status: z.unknown().optional(), + success: z.unknown().optional(), + failure: z.unknown().optional(), + }) + .loose(); + +// --------------------------------------------------------------------------- +// List group permissions +// --------------------------------------------------------------------------- + +export const ListGroupsCreateInput = z.object({ + listid: z.string(), + groupid: z.string(), +}); + +export const ListGroupsCreateOutput = z + .object({ listGroup: ActiveCampaignListGroup }) + .loose(); + +// --------------------------------------------------------------------------- +// Builders for the standard resource shape +// +// Most v3 resources share one contract, so the schemas are built rather than +// retyped: a paginated collection under a plural key, a single record under a +// singular key, and a delete returning the id it removed. +// --------------------------------------------------------------------------- + +/** `{ : Entity[], meta }` */ +function listOf(key: K, entity: T) { + return z + .object({ [key]: z.array(entity), meta: Meta } as Record>) + .loose(); +} + +/** `{ : Entity }` */ +function oneOf(key: K, entity: T) { + return z.object({ [key]: entity } as Record).loose(); +} + +const IdInput = z.object({ id: z.string() }); +const IdOutput = z.object({ id: z.string() }).loose(); +const PageInput = z.object({ ...PaginationInput }); + +// --------------------------------------------------------------------------- +// CRM: deals +// --------------------------------------------------------------------------- + +export const DealsListInput = z.object({ + ...PaginationInput, + search: z.string().optional(), + search_field: z.enum(['all', 'title', 'contact', 'org']).optional(), + title: z.string().optional(), + stage: z.string().optional(), + group: z.string().optional(), + status: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), + owner: z.string().optional(), + nextdate_range: z + .enum(['upcoming', 'scheduled', 'overdue', 'no-task']) + .optional(), + tag: z.string().optional(), + tasktype: z.string().optional(), + created_before: z.string().optional(), + created_after: z.string().optional(), + updated_before: z.string().optional(), + updated_after: z.string().optional(), + organization: z.string().optional(), + minimum_value: z.number().optional(), + maximum_value: z.number().optional(), + score_greater_than: z.number().optional(), + score_less_than: z.number().optional(), + score: z.number().optional(), + order_id: z.enum(['ASC', 'DESC']).optional(), + order_title: z.enum(['ASC', 'DESC']).optional(), + order_value: z.enum(['ASC', 'DESC']).optional(), + order_created: z.enum(['ASC', 'DESC']).optional(), + order_updated: z.enum(['ASC', 'DESC']).optional(), + order_contact_name: z.enum(['ASC', 'DESC']).optional(), + order_contact_orgname: z.enum(['ASC', 'DESC']).optional(), + order_next_action: z.enum(['ASC', 'DESC']).optional(), +}); +export const DealsListOutput = listOf('deals', ActiveCampaignDeal); + +export const DealsGetInput = IdInput; +export const DealsGetOutput = oneOf('deal', ActiveCampaignDeal); + +export const DealsUpdateInput = z.object({ + id: z.string(), + title: z.string().optional(), + description: z.string().optional(), + value: z.number().int().optional(), + currency: z.string().optional(), + group: z.string().optional(), + stage: z.string().optional(), + owner: z.string().optional(), + contact: z.string().optional(), + organization: z.string().optional(), + status: z.number().int().optional(), + percent: z.number().int().optional(), + fields: z + .array( + z.object({ + customFieldId: z.number().int(), + fieldValue: z.union([z.string(), z.number(), z.array(z.string())]), + }), + ) + .optional(), +}); +export const DealsUpdateOutput = DealsGetOutput; + +export const DealsDeleteInput = IdInput; +export const DealsDeleteOutput = IdOutput; + +/** + * Reassigns many deals at once. The whole batch is one request, so a retry + * re-applies every reassignment - hence non-idempotent. + */ +export const DealsUpdateOwnersBulkInput = z.object({ + deals: z + .array(z.object({ id: z.number().int(), ownerId: z.number().int() })) + .min(1), +}); +export const DealsUpdateOwnersBulkOutput = z.object({}).loose(); + +// Pipelines +export const DealGroupsListInput = z.object({ + ...PaginationInput, + title: z.string().optional(), +}); +export const DealGroupsListOutput = listOf( + 'dealGroups', + ActiveCampaignDealGroup, +); +export const DealGroupsGetInput = IdInput; +export const DealGroupsGetOutput = oneOf('dealGroup', ActiveCampaignDealGroup); +export const DealGroupsCreateInput = z.object({ + title: z.string().min(1), + currency: z.string().optional(), + allgroups: z.union([z.literal(0), z.literal(1)]).optional(), + allusers: z.union([z.literal(0), z.literal(1)]).optional(), + autoassign: z.union([z.literal(0), z.literal(1)]).optional(), + users: z.array(z.union([z.string(), z.number().int()])).optional(), + groups: z.array(z.union([z.string(), z.number().int()])).optional(), +}); +export const DealGroupsCreateOutput = DealGroupsGetOutput; +export const DealGroupsUpdateInput = DealGroupsCreateInput.partial().extend({ + id: z.string(), +}); +export const DealGroupsUpdateOutput = DealGroupsGetOutput; +export const DealGroupsDeleteInput = IdInput; +export const DealGroupsDeleteOutput = IdOutput; + +// Stages +export const DealStagesListInput = z.object({ + ...PaginationInput, + title: z.string().optional(), + group: z.string().optional(), +}); +export const DealStagesListOutput = listOf( + 'dealStages', + ActiveCampaignDealStage, +); +export const DealStagesGetInput = IdInput; +export const DealStagesGetOutput = oneOf('dealStage', ActiveCampaignDealStage); +export const DealStagesCreateInput = z.object({ + title: z.string().min(1), + group: z.string(), + order: z.number().int().optional(), + width: z.number().int().optional(), + color: z.string().optional(), + cardRegion1: z.string().optional(), +}); +export const DealStagesCreateOutput = DealStagesGetOutput; +export const DealStagesUpdateInput = DealStagesCreateInput.partial().extend({ + id: z.string(), +}); +export const DealStagesUpdateOutput = DealStagesGetOutput; +export const DealStagesDeleteInput = IdInput; +export const DealStagesDeleteOutput = IdOutput; + +export const DealStagesMoveDealsInput = z.object({ + id: z.string(), + /** Target stage. Must belong to the same pipeline, or the API answers 422. */ + stage: z.string(), +}); +export const DealStagesMoveDealsOutput = z.object({}).loose(); + +/** + * Deleting a stage destroys the deals in it unless they are moved first, so + * `action_type: 'Move'` requires both target ids. Encoded as a refinement + * rather than left to the caller. + */ +export const DealStagesDeleteWithDealsInput = z + .object({ + id: z.string(), + action_type: z.enum(['Move', 'Delete']), + new_pipeline_id: z.string().optional(), + new_stage_id: z.string().optional(), + }) + .refine( + (v) => + v.action_type !== 'Move' || + (v.new_pipeline_id !== undefined && v.new_stage_id !== undefined), + { + message: + "action_type 'Move' requires both new_pipeline_id and new_stage_id", + path: ['new_stage_id'], + }, + ); +export const DealStagesDeleteWithDealsOutput = IdOutput; + +// Tasks +export const DealTasksListInput = z.object({ + ...PaginationInput, + title: z.string().optional(), + reltype: z.string().optional(), + relid: z.string().optional(), + status: z.number().int().optional(), + note: z.string().optional(), + duedate: z.string().optional(), + dealTasktype: z.string().optional(), + userid: z.string().optional(), + due_after: z.string().optional(), + due_before: z.string().optional(), + duedate_range: z.string().optional(), + assignee_userid: z.string().optional(), + outcome_id: z.string().optional(), +}); +export const DealTasksListOutput = listOf('dealTasks', ActiveCampaignDealTask); +export const DealTasksGetInput = IdInput; +export const DealTasksGetOutput = oneOf('dealTask', ActiveCampaignDealTask); +export const DealTasksCreateInput = z.object({ + title: z.string().nullable().optional(), + relid: z.string(), + reltype: z.enum(['Deal', 'Subscriber', 'Account']), + dealTasktype: z.string(), + ownerType: z.string().optional(), + status: z.number().int().optional(), + note: z.string().optional(), + duedate: z.string(), + edate: z.string().optional(), + assignee: z.string().optional(), + triggerAutomationOnCreate: z.boolean().optional(), + doneAutomation: z.boolean().optional(), + outcomeId: z.string().optional(), + outcomeInfo: z.string().optional(), +}); +export const DealTasksCreateOutput = DealTasksGetOutput; +export const DealTasksUpdateInput = DealTasksCreateInput.partial().extend({ + id: z.string(), +}); +export const DealTasksUpdateOutput = DealTasksGetOutput; +export const DealTasksDeleteInput = IdInput; +export const DealTasksDeleteOutput = IdOutput; + +export const DealTaskTypesListInput = PageInput; +export const DealTaskTypesListOutput = listOf( + 'dealTasktypes', + ActiveCampaignDealTaskType, +); +export const DealTaskTypesGetInput = IdInput; +export const DealTaskTypesGetOutput = oneOf( + 'dealTasktype', + ActiveCampaignDealTaskType, +); +export const DealTaskTypesCreateInput = z.object({ + title: z.string().min(1), + defduration: z.string().optional(), + status: z.number().int().optional(), + display_order: z.number().int().optional(), + outcomes: z.array(z.string()).optional(), +}); +export const DealTaskTypesCreateOutput = DealTaskTypesGetOutput; +export const DealTaskTypesUpdateInput = + DealTaskTypesCreateInput.partial().extend({ id: z.string() }); +export const DealTaskTypesUpdateOutput = DealTaskTypesGetOutput; + +export const TaskOutcomesListInput = PageInput; +export const TaskOutcomesListOutput = listOf( + 'taskOutcomes', + ActiveCampaignTaskOutcome, +); +export const TaskOutcomesGetInput = IdInput; +export const TaskOutcomesGetOutput = oneOf( + 'taskOutcome', + ActiveCampaignTaskOutcome, +); +export const TaskOutcomesCreateInput = z.object({ + title: z.string().min(1), + sentiment: z.enum(['positive', 'neutral', 'negative']).optional(), + status: z.number().int().optional(), + dealTasktypes: z.array(z.string()).optional(), +}); +export const TaskOutcomesCreateOutput = TaskOutcomesGetOutput; + +// Roles +export const DealRolesListInput = PageInput; +export const DealRolesListOutput = listOf('dealRoles', ActiveCampaignDealRole); +export const DealRolesCreateInput = z.object({ title: z.string().min(1) }); +export const DealRolesCreateOutput = oneOf('dealRole', ActiveCampaignDealRole); +export const DealRolesDeleteInput = IdInput; +export const DealRolesDeleteOutput = IdOutput; + +// Secondary contacts +export const ContactDealsListInput = z.object({ ...PaginationInput }); +export const ContactDealsListOutput = z + .object({ contactDeals: z.array(z.unknown()), meta: Meta }) + .loose(); +export const ContactDealsGetInput = IdInput; +export const ContactDealsGetOutput = z + .object({ contactDeal: z.unknown() }) + .loose(); +export const ContactDealsCreateInput = z.object({ + contact: z.string(), + deal: z.string(), + role: z.string().optional(), + jobTitle: z.string().optional(), +}); +export const ContactDealsCreateOutput = ContactDealsGetOutput; +export const ContactDealsUpdateInput = ContactDealsCreateInput.partial().extend( + { + id: z.string(), + }, +); +export const ContactDealsUpdateOutput = ContactDealsGetOutput; +export const ContactDealsDeleteInput = IdInput; +export const ContactDealsDeleteOutput = IdOutput; + +// Deal custom fields +export const DealCustomFieldMetaListInput = PageInput; +export const DealCustomFieldMetaListOutput = listOf( + 'dealCustomFieldMeta', + ActiveCampaignDealCustomFieldMeta, +); +export const DealCustomFieldMetaGetInput = IdInput; +export const DealCustomFieldMetaGetOutput = oneOf( + 'dealCustomFieldMetum', + ActiveCampaignDealCustomFieldMeta, +); +export const DealCustomFieldMetaCreateInput = z.object({ + fieldLabel: z.string().min(1), + fieldType: z.enum([ + 'text', + 'textarea', + 'date', + 'dropdown', + 'multiselect', + 'radio', + 'checkbox', + 'hidden', + 'number', + 'currency', + 'datetime', + ]), + fieldOptions: z.array(z.string()).optional(), + fieldDefault: z + .union([z.string(), z.number(), z.array(z.string())]) + .optional(), + fieldDefaultCurrency: z.string().optional(), + isFormVisible: z.number().int().optional(), + isRequired: z.number().int().optional(), + displayOrder: z.number().int().optional(), +}); +export const DealCustomFieldMetaCreateOutput = DealCustomFieldMetaGetOutput; +export const DealCustomFieldMetaUpdateInput = + DealCustomFieldMetaCreateInput.partial().extend({ id: z.string() }); +export const DealCustomFieldMetaUpdateOutput = DealCustomFieldMetaGetOutput; +export const DealCustomFieldMetaDeleteInput = IdInput; +export const DealCustomFieldMetaDeleteOutput = IdOutput; + +export const DealCustomFieldDataListInput = z.object({ + ...PaginationInput, + dealId: z.string().optional(), +}); +export const DealCustomFieldDataListOutput = z + .object({ dealCustomFieldData: z.array(z.unknown()), meta: Meta }) + .loose(); +export const DealCustomFieldDataGetInput = IdInput; +export const DealCustomFieldDataGetOutput = z + .object({ dealCustomFieldDatum: z.unknown() }) + .loose(); +export const DealCustomFieldDataUpdateInput = z.object({ + id: z.string(), + dealId: z.number().int().optional(), + customFieldId: z.number().int().optional(), + fieldValue: z.union([z.string(), z.number(), z.array(z.string())]), + fieldCurrency: z.string().optional(), +}); +export const DealCustomFieldDataUpdateOutput = DealCustomFieldDataGetOutput; +export const DealCustomFieldDataDeleteInput = IdInput; +export const DealCustomFieldDataDeleteOutput = IdOutput; + +// Activities +export const DealActivitiesListInput = z.object({ + ...PaginationInput, + deal: z.string().optional(), + exclude: z.string().optional(), + data_type: z.string().optional(), + data_id: z.string().optional(), +}); +export const DealActivitiesListOutput = z + .object({ dealActivities: z.array(z.unknown()), meta: Meta }) + .loose(); + +// --------------------------------------------------------------------------- +// CRM: accounts, account contacts, account custom fields, notes +// --------------------------------------------------------------------------- + +export const AccountsListInput = z.object({ + ...PaginationInput, + search: z.string().optional(), +}); +export const AccountsListOutput = listOf('accounts', ActiveCampaignAccount); +export const AccountsGetInput = IdInput; +export const AccountsGetOutput = oneOf('account', ActiveCampaignAccount); +export const AccountsCreateInput = z.object({ + name: z.string().min(1), + accountUrl: z.string().optional(), + owner: z.string().optional(), + fields: z + .array( + z.object({ customFieldId: z.number().int(), fieldValue: z.string() }), + ) + .optional(), +}); +export const AccountsCreateOutput = AccountsGetOutput; +export const AccountsUpdateInput = AccountsCreateInput.partial().extend({ + id: z.string(), +}); +export const AccountsUpdateOutput = AccountsGetOutput; +export const AccountsDeleteInput = IdInput; +export const AccountsDeleteOutput = IdOutput; + +/** Account names are unique, so the name is the matching key. */ +export const AccountsUpsertInput = AccountsCreateInput; +export const AccountsUpsertOutput = AccountsGetOutput; + +export const AccountsDeleteBulkInput = z.object({ + ids: z.array(z.union([z.string(), z.number().int()])).min(1), +}); +export const AccountsDeleteBulkOutput = z + .object({ ids: z.array(z.union([z.string(), z.number().int()])) }) + .loose(); + +export const AccountContactsListInput = z.object({ + ...PaginationInput, + contact: z.string().optional(), + account: z.string().optional(), +}); +export const AccountContactsListOutput = listOf( + 'accountContacts', + ActiveCampaignAccountContact, +); +export const AccountContactsGetInput = IdInput; +export const AccountContactsGetOutput = oneOf( + 'accountContact', + ActiveCampaignAccountContact, +); +export const AccountContactsCreateInput = z.object({ + contact: z.string(), + account: z.string(), + jobTitle: z.string().optional(), +}); +export const AccountContactsCreateOutput = AccountContactsGetOutput; +export const AccountContactsUpdateInput = + AccountContactsCreateInput.partial().extend({ id: z.string() }); +export const AccountContactsUpdateOutput = AccountContactsGetOutput; +export const AccountContactsDeleteInput = IdInput; +export const AccountContactsDeleteOutput = IdOutput; + +export const AccountCustomFieldMetaListInput = PageInput; +export const AccountCustomFieldMetaListOutput = listOf( + 'accountCustomFieldMeta', + ActiveCampaignAccountCustomFieldMeta, +); +export const AccountCustomFieldMetaGetInput = IdInput; +export const AccountCustomFieldMetaGetOutput = oneOf( + 'accountCustomFieldMetum', + ActiveCampaignAccountCustomFieldMeta, +); +export const AccountCustomFieldMetaCreateInput = z.object({ + fieldLabel: z.string().min(1), + fieldType: z.string(), + fieldOptions: z.array(z.string()).optional(), + fieldDefault: z.string().optional(), + fieldDefaultCurrency: z.string().optional(), + isFormVisible: z.number().int().optional(), + isRequired: z.number().int().optional(), + displayOrder: z.number().int().optional(), +}); +export const AccountCustomFieldMetaCreateOutput = + AccountCustomFieldMetaGetOutput; +export const AccountCustomFieldMetaUpdateInput = + AccountCustomFieldMetaCreateInput.partial().extend({ id: z.string() }); +export const AccountCustomFieldMetaUpdateOutput = + AccountCustomFieldMetaGetOutput; +export const AccountCustomFieldMetaDeleteInput = IdInput; +export const AccountCustomFieldMetaDeleteOutput = IdOutput; + +export const AccountCustomFieldDataListInput = z.object({ + ...PaginationInput, + accountId: z.string().optional(), +}); +export const AccountCustomFieldDataListOutput = z + .object({ accountCustomFieldData: z.array(z.unknown()), meta: Meta }) + .loose(); +export const AccountCustomFieldDataGetInput = IdInput; +export const AccountCustomFieldDataGetOutput = z + .object({ accountCustomFieldDatum: z.unknown() }) + .loose(); +export const AccountCustomFieldDataCreateInput = z.object({ + accountId: z.number().int(), + customFieldId: z.number().int(), + fieldValue: z.string(), +}); +export const AccountCustomFieldDataCreateOutput = + AccountCustomFieldDataGetOutput; +export const AccountCustomFieldDataUpdateInput = z.object({ + id: z.string(), + fieldValue: z.string(), +}); +export const AccountCustomFieldDataUpdateOutput = + AccountCustomFieldDataGetOutput; +export const AccountCustomFieldDataDeleteInput = IdInput; +export const AccountCustomFieldDataDeleteOutput = IdOutput; + +const AccountFieldDatum = z.object({ + accountId: z.number().int(), + customFieldId: z.number().int(), + fieldValue: z.string(), +}); +export const AccountCustomFieldDataCreateBulkInput = z.object({ + items: z.array(AccountFieldDatum).min(1), +}); +export const AccountCustomFieldDataCreateBulkOutput = z.object({}).loose(); +export const AccountCustomFieldDataUpdateBulkInput = z.object({ + items: z + .array(AccountFieldDatum.partial().extend({ id: z.number().int() })) + .min(1), +}); +export const AccountCustomFieldDataUpdateBulkOutput = z.object({}).loose(); + +export const NotesListInput = z.object({ + ...PaginationInput, + reltype: z.string().optional(), + relid: z.string().optional(), +}); +export const NotesListOutput = listOf('notes', ActiveCampaignNote); +export const NotesGetInput = IdInput; +export const NotesGetOutput = oneOf('note', ActiveCampaignNote); +export const NotesCreateInput = z.object({ + note: z.string().min(1), + reltype: z.enum(['Subscriber', 'Deal', 'Account']), + relid: z.string(), +}); +export const NotesCreateOutput = NotesGetOutput; +export const NotesUpdateInput = z.object({ + id: z.string(), + note: z.string().min(1), +}); +export const NotesUpdateOutput = NotesGetOutput; +export const NotesDeleteInput = IdInput; +export const NotesDeleteOutput = IdOutput; + +/** Resolves the contact by email, then attaches the note to it. */ +export const NotesAddToContactInput = z.object({ + email: z.email(), + note: z.string().min(1), +}); +export const NotesAddToContactOutput = NotesGetOutput; + +// --------------------------------------------------------------------------- +// Campaigns, messaging, forms, variables, automations, segments +// --------------------------------------------------------------------------- + +export const CampaignsListInput = z.object({ + ...PaginationInput, + type: z.string().optional(), + status: z.string().optional(), +}); +export const CampaignsListOutput = listOf('campaigns', ActiveCampaignCampaign); +export const CampaignsGetInput = IdInput; +export const CampaignsGetOutput = oneOf('campaign', ActiveCampaignCampaign); +export const CampaignsCreateInput = z.object({ + type: z.string(), + name: z.string().min(1), + status: z.number().int().optional(), + sdate: z.string().optional(), + segmentid: z.number().int().optional(), + p: z.record(z.string(), z.unknown()).optional(), + m: z.record(z.string(), z.unknown()).optional(), +}); +export const CampaignsCreateOutput = CampaignsGetOutput; +export const CampaignsUpdateInput = z.object({ + id: z.string(), + name: z.string().min(1).optional(), + status: z.number().int().optional(), +}); +export const CampaignsUpdateOutput = CampaignsGetOutput; +export const CampaignsDuplicateInput = IdInput; +export const CampaignsDuplicateOutput = CampaignsGetOutput; + +const CampaignSubInput = z.object({ id: z.string(), ...PaginationInput }); +export const CampaignsGetLinksInput = CampaignSubInput; +export const CampaignsGetLinksOutput = z + .object({ links: z.array(z.unknown()), meta: Meta }) + .loose(); +export const CampaignsGetMessagesInput = CampaignSubInput; +export const CampaignsGetMessagesOutput = z + .object({ campaignMessages: z.array(z.unknown()), meta: Meta }) + .loose(); +export const CampaignsGetAutomationsInput = CampaignSubInput; +export const CampaignsGetAutomationsOutput = z + .object({ automations: z.array(z.unknown()), meta: Meta }) + .loose(); +export const CampaignsGetAutomationListsInput = CampaignSubInput; +export const CampaignsGetAutomationListsOutput = z + .object({ campaignLists: z.array(z.unknown()), meta: Meta }) + .loose(); +export const CampaignsGetUserInput = CampaignSubInput; +export const CampaignsGetUserOutput = z.object({ user: z.unknown() }).loose(); + +export const MessagesListInput = PageInput; +export const MessagesListOutput = listOf('messages', ActiveCampaignMessage); +export const MessagesGetInput = IdInput; +export const MessagesGetOutput = oneOf('message', ActiveCampaignMessage); +export const MessagesCreateInput = z.object({ + subject: z.string().min(1), + fromname: z.string().min(1), + fromemail: z.email(), + reply2: z.email(), + html: z.string().optional(), + text: z.string().optional(), + name: z.string().optional(), + format: z.enum(['html', 'text', 'mime']).optional(), + user: z.string().optional(), + preheader_text: z.string().optional(), +}); +export const MessagesCreateOutput = MessagesGetOutput; +export const MessagesUpdateInput = MessagesCreateInput.partial().extend({ + id: z.string(), +}); +export const MessagesUpdateOutput = MessagesGetOutput; +export const MessagesDeleteInput = IdInput; +export const MessagesDeleteOutput = IdOutput; + +export const SavedResponsesListInput = PageInput; +export const SavedResponsesListOutput = listOf( + 'savedResponses', + ActiveCampaignSavedResponse, +); +export const SavedResponsesGetInput = IdInput; +export const SavedResponsesGetOutput = oneOf( + 'savedResponse', + ActiveCampaignSavedResponse, +); +export const SavedResponsesCreateInput = z.object({ + title: z.string().min(1), + subject: z.string().min(1), + body: z.string().min(1), + userid: z.string().optional(), +}); +export const SavedResponsesCreateOutput = SavedResponsesGetOutput; +export const SavedResponsesUpdateInput = + SavedResponsesCreateInput.partial().extend({ id: z.string() }); +export const SavedResponsesUpdateOutput = SavedResponsesGetOutput; +export const SavedResponsesDeleteInput = IdInput; +export const SavedResponsesDeleteOutput = IdOutput; + +export const FormsListInput = PageInput; +export const FormsListOutput = listOf('forms', ActiveCampaignForm); +export const FormsGetInput = IdInput; +export const FormsGetOutput = oneOf('form', ActiveCampaignForm); +export const FormsDeleteInput = IdInput; +export const FormsDeleteOutput = IdOutput; + +/** Recording an opt-in is a consent action, so the form is required. */ +export const FormsCreateOptinInput = z.object({ + formid: z.string(), + email: z.email(), + firstName: z.string().optional(), + lastName: z.string().optional(), +}); +export const FormsCreateOptinOutput = z.object({}).loose(); + +export const PersonalizationsListInput = PageInput; +export const PersonalizationsListOutput = listOf( + 'personalizations', + ActiveCampaignPersonalization, +); +export const PersonalizationsGetInput = IdInput; +export const PersonalizationsGetOutput = oneOf( + 'personalization', + ActiveCampaignPersonalization, +); +export const PersonalizationsCreateInput = z.object({ + name: z.string().min(1), + tag: z.string().min(1), + content: z.string(), + format: z.string().optional(), + lists: z.array(z.string()).optional(), +}); +export const PersonalizationsCreateOutput = PersonalizationsGetOutput; +export const PersonalizationsUpdateInput = + PersonalizationsCreateInput.partial().extend({ id: z.string() }); +export const PersonalizationsUpdateOutput = PersonalizationsGetOutput; +export const PersonalizationsDeleteInput = IdInput; +export const PersonalizationsDeleteOutput = IdOutput; +export const PersonalizationsDeleteBulkInput = z.object({ + ids: z.array(z.union([z.string(), z.number().int()])).min(1), +}); +export const PersonalizationsDeleteBulkOutput = z + .object({ ids: z.array(z.union([z.string(), z.number().int()])) }) + .loose(); +export const PersonalizationsLockInput = IdInput; +export const PersonalizationsLockOutput = PersonalizationsGetOutput; +export const PersonalizationsUnlockInput = IdInput; +export const PersonalizationsUnlockOutput = PersonalizationsGetOutput; + +export const TemplatesGetInput = IdInput; +export const TemplatesGetOutput = oneOf('template', ActiveCampaignTemplate); +export const TemplatesCreateShareLinkInput = IdInput; +export const TemplatesCreateShareLinkOutput = z.object({}).loose(); + +export const AutomationsListInput = PageInput; +export const AutomationsListOutput = listOf( + 'automations', + ActiveCampaignAutomation, +); + +export const ContactAutomationsListInput = PageInput; +export const ContactAutomationsListOutput = z + .object({ contactAutomations: z.array(z.unknown()), meta: Meta }) + .loose(); +export const ContactAutomationsGetInput = IdInput; +export const ContactAutomationsGetOutput = z + .object({ contactAutomation: z.unknown() }) + .loose(); +export const ContactAutomationsEntryCountsInput = IdInput; +export const ContactAutomationsEntryCountsOutput = z.object({}).loose(); + +/** + * Automations cannot be created through the API - only through the UI - so the + * automation must already exist. + */ +export const ContactAutomationsAddInput = z.object({ + email: z.email(), + automation_id: z.string(), +}); +export const ContactAutomationsAddOutput = ContactAutomationsGetOutput; + +/** + * A contact can hold several enrolments in one automation. `all` removes every + * run, `last` removes only the most recent. + */ +export const ContactAutomationsRemoveInput = z.object({ + email: z.email(), + automation_id: z.string(), + run_remove_option: z.enum(['all', 'last']).optional(), +}); +export const ContactAutomationsRemoveOutput = z + .object({ removed: z.number() }) + .loose(); + +export const SegmentsListInput = PageInput; +export const SegmentsListOutput = listOf('segments', ActiveCampaignSegment); +export const SegmentsGetInput = IdInput; +export const SegmentsGetOutput = oneOf('segment', ActiveCampaignSegment); +export const SegmentsCreateInput = z.object({ + name: z.string().min(1), + logic: z.string().optional(), +}); +export const SegmentsCreateOutput = SegmentsGetOutput; +export const SegmentsUpdateInput = SegmentsCreateInput.partial().extend({ + id: z.string(), +}); +export const SegmentsUpdateOutput = SegmentsGetOutput; +export const SegmentsDeleteInput = IdInput; +export const SegmentsDeleteOutput = IdOutput; +export const SegmentsListAudiencesInput = z.object({ + ...PaginationInput, + name: z.string().optional(), +}); +export const SegmentsListAudiencesOutput = SegmentsListOutput; + +// --------------------------------------------------------------------------- +// E-commerce, custom objects, tracking, webhooks, administration +// --------------------------------------------------------------------------- + +const ConnectionFilter = z.object({ + ...PaginationInput, + service: z.string().optional(), + externalid: z.string().optional(), +}); +export const ConnectionsListInput = ConnectionFilter; +export const ConnectionsListOutput = listOf( + 'connections', + ActiveCampaignConnection, +); +export const ConnectionsGetInput = IdInput; +export const ConnectionsGetOutput = oneOf( + 'connection', + ActiveCampaignConnection, +); +export const ConnectionsCreateInput = z.object({ + service: z.string().min(1), + externalid: z.string().min(1), + name: z.string().min(1), + logoUrl: z.url().optional(), + linkUrl: z.url().optional(), +}); +export const ConnectionsCreateOutput = ConnectionsGetOutput; +export const ConnectionsUpdateInput = ConnectionsCreateInput.partial().extend({ + id: z.string(), +}); +export const ConnectionsUpdateOutput = ConnectionsGetOutput; +export const ConnectionsDeleteInput = IdInput; +export const ConnectionsDeleteOutput = IdOutput; + +export const EcomCustomersListInput = z.object({ + ...PaginationInput, + connectionid: z.string().optional(), + externalid: z.string().optional(), +}); +export const EcomCustomersListOutput = listOf( + 'ecomCustomers', + ActiveCampaignEcomCustomer, +); +export const EcomCustomersGetInput = IdInput; +export const EcomCustomersGetOutput = oneOf( + 'ecomCustomer', + ActiveCampaignEcomCustomer, +); +export const EcomCustomersCreateInput = z.object({ + connectionid: z.string(), + externalid: z.string(), + email: z.email(), + acceptsMarketing: z.string().optional(), +}); +export const EcomCustomersCreateOutput = EcomCustomersGetOutput; +export const EcomCustomersUpdateInput = + EcomCustomersCreateInput.partial().extend({ id: z.string() }); +export const EcomCustomersUpdateOutput = EcomCustomersGetOutput; +export const EcomCustomersDeleteInput = IdInput; +export const EcomCustomersDeleteOutput = IdOutput; + +/** Orders are transactional and are returned but never mirrored. */ +export const EcomOrdersListInput = z.object({ + ...PaginationInput, + connectionid: z.string().optional(), + customerid: z.string().optional(), +}); +export const EcomOrdersListOutput = z + .object({ ecomOrders: z.array(z.unknown()), meta: Meta }) + .loose(); +export const EcomOrdersGetInput = IdInput; +export const EcomOrdersGetOutput = z.object({ ecomOrder: z.unknown() }).loose(); +export const EcomOrdersCreateInput = z.object({ + externalid: z.string(), + source: z.number().int(), + email: z.email(), + orderProducts: z.array(z.record(z.string(), z.unknown())), + orderDiscounts: z.array(z.record(z.string(), z.unknown())).optional(), + totalPrice: z.number().int(), + shippingAmount: z.number().int().optional(), + taxAmount: z.number().int().optional(), + discountAmount: z.number().int().optional(), + currency: z.string(), + orderDate: z.string(), + externalUpdatedDate: z.string().optional(), + abandonedDate: z.string().optional(), + externalcheckoutid: z.string().optional(), + connectionid: z.string(), + customerid: z.string(), + orderNumber: z.string().optional(), + shippingMethod: z.string().optional(), +}); +export const EcomOrdersCreateOutput = EcomOrdersGetOutput; +export const EcomOrdersUpdateInput = EcomOrdersCreateInput.partial().extend({ + id: z.string(), +}); +export const EcomOrdersUpdateOutput = EcomOrdersGetOutput; +export const EcomOrdersDeleteInput = IdInput; +export const EcomOrdersDeleteOutput = IdOutput; + +export const EcomOrderProductsListInput = PageInput; +export const EcomOrderProductsListOutput = z + .object({ ecomOrderProducts: z.array(z.unknown()), meta: Meta }) + .loose(); +export const EcomOrderProductsGetInput = IdInput; +export const EcomOrderProductsGetOutput = z + .object({ ecomOrderProduct: z.unknown() }) + .loose(); + +export const CustomObjectSchemasListInput = PageInput; +export const CustomObjectSchemasListOutput = listOf( + 'schemas', + ActiveCampaignCustomObjectSchema, +); +export const CustomObjectSchemasGetInput = IdInput; +export const CustomObjectSchemasGetOutput = oneOf( + 'schema', + ActiveCampaignCustomObjectSchema, +); +export const CustomObjectSchemasCreateInput = z.object({ + slug: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + labels: z.record(z.string(), z.unknown()).optional(), + fields: z.array(z.record(z.string(), z.unknown())).optional(), + relationships: z.array(z.record(z.string(), z.unknown())).optional(), +}); +export const CustomObjectSchemasCreateOutput = CustomObjectSchemasGetOutput; +export const CustomObjectSchemasUpdateInput = + CustomObjectSchemasCreateInput.partial().extend({ id: z.string() }); +export const CustomObjectSchemasUpdateOutput = CustomObjectSchemasGetOutput; +export const CustomObjectSchemasDeleteInput = IdInput; +export const CustomObjectSchemasDeleteOutput = IdOutput; + +export const CustomObjectRecordsListInput = z.object({ + schemaId: z.string(), + ...PaginationInput, +}); +export const CustomObjectRecordsListOutput = z + .object({ records: z.array(z.unknown()), meta: Meta }) + .loose(); +/** Upsert: an existing external ID updates, anything else creates. */ +export const CustomObjectRecordsUpsertInput = z.object({ + schemaId: z.string(), + externalId: z.string().optional(), + fields: z.array(z.record(z.string(), z.unknown())), +}); +export const CustomObjectRecordsUpsertOutput = z + .object({ record: z.unknown() }) + .loose(); +export const CustomObjectRecordsGetInput = z.object({ + schemaId: z.string(), + id: z.string(), +}); +export const CustomObjectRecordsGetOutput = CustomObjectRecordsUpsertOutput; +export const CustomObjectRecordsGetByExternalIdInput = z.object({ + schemaId: z.string(), + externalId: z.string(), +}); +export const CustomObjectRecordsGetByExternalIdOutput = + CustomObjectRecordsUpsertOutput; +export const CustomObjectRecordsDeleteInput = CustomObjectRecordsGetInput; +export const CustomObjectRecordsDeleteOutput = z + .object({ schemaId: z.string(), id: z.string().optional() }) + .loose(); +export const CustomObjectRecordsDeleteByExternalIdInput = + CustomObjectRecordsGetByExternalIdInput; +export const CustomObjectRecordsDeleteByExternalIdOutput = z + .object({ schemaId: z.string(), externalId: z.string().optional() }) + .loose(); + +export const WebhooksListInput = PageInput; +export const WebhooksListOutput = listOf('webhooks', ActiveCampaignWebhook); +export const WebhooksGetInput = IdInput; +export const WebhooksGetOutput = oneOf('webhook', ActiveCampaignWebhook); +export const WebhooksCreateInput = z.object({ + name: z.string().min(1), + url: z.url(), + events: z.array(z.string()).min(1), + sources: z.array(z.enum(['public', 'admin', 'api', 'system'])).min(1), + listid: z.string().optional(), +}); +export const WebhooksCreateOutput = WebhooksGetOutput; +export const WebhooksUpdateInput = WebhooksCreateInput.partial().extend({ + id: z.string(), +}); +export const WebhooksUpdateOutput = WebhooksGetOutput; +export const WebhooksDeleteInput = IdInput; +export const WebhooksDeleteOutput = IdOutput; + +export const UsersListInput = PageInput; +export const UsersListOutput = listOf('users', ActiveCampaignUser); +export const UsersGetInput = IdInput; +export const UsersGetOutput = oneOf('user', ActiveCampaignUser); +export const UsersCreateInput = z.object({ + username: z.string().min(1), + email: z.email(), + firstName: z.string().min(1), + lastName: z.string().min(1), + password: z.string().min(1), + group: z.string(), + phone: z.string().optional(), + signature: z.string().optional(), + lang: z.string().optional(), + localZoneid: z.string().optional(), +}); +export const UsersCreateOutput = UsersGetOutput; +export const UsersUpdateInput = UsersCreateInput.partial().extend({ + id: z.string(), +}); +export const UsersUpdateOutput = UsersGetOutput; +export const UsersDeleteInput = IdInput; +export const UsersDeleteOutput = IdOutput; +export const UsersGetMeInput = z.object({}); +export const UsersGetMeOutput = UsersGetOutput; +export const UsersGetByUsernameInput = z.object({ + username: z.string().min(1), +}); +export const UsersGetByUsernameOutput = UsersGetOutput; + +export const GroupsListInput = PageInput; +export const GroupsListOutput = listOf('groups', ActiveCampaignGroup); +export const GroupsGetInput = IdInput; +export const GroupsGetOutput = oneOf('group', ActiveCampaignGroup); +export const GroupsCreateInput = z.object({ + title: z.string().min(1), + descript: z.string().optional(), +}); +export const GroupsCreateOutput = GroupsGetOutput; +export const GroupsUpdateInput = GroupsCreateInput.partial().extend({ + id: z.string(), +}); +export const GroupsUpdateOutput = GroupsGetOutput; +export const GroupsDeleteInput = IdInput; +export const GroupsDeleteOutput = IdOutput; +export const GroupLimitsListInput = PageInput; +export const GroupLimitsListOutput = listOf( + 'groupLimits', + ActiveCampaignGroupLimit, +); + +export const AddressesListInput = PageInput; +export const AddressesListOutput = listOf('addresses', ActiveCampaignAddress); +export const AddressesGetInput = IdInput; +export const AddressesGetOutput = oneOf('address', ActiveCampaignAddress); +export const AddressesCreateInput = z.object({ + companyName: z.string().min(1), + address1: z.string().min(1), + address2: z.string().optional(), + city: z.string().min(1), + state: z.string().optional(), + zip: z.string().optional(), + country: z.string().min(2), + allgroups: z.boolean().optional(), + groupid: z.string().optional(), +}); +export const AddressesCreateOutput = AddressesGetOutput; +export const AddressesUpdateInput = AddressesCreateInput.partial().extend({ + id: z.string(), +}); +export const AddressesUpdateOutput = AddressesGetOutput; +export const AddressesDeleteInput = IdInput; +export const AddressesDeleteOutput = IdOutput; + +export const CalendarsListInput = PageInput; +export const CalendarsListOutput = listOf('calendars', ActiveCampaignCalendar); +export const CalendarsGetInput = IdInput; +export const CalendarsGetOutput = oneOf('calendar', ActiveCampaignCalendar); +export const CalendarsCreateInput = z.object({ + title: z.string().min(1), + type: z.string().optional(), + description: z.string().optional(), + isglobal: z.boolean().optional(), + inviteusers: z.array(z.string()).optional(), +}); +export const CalendarsCreateOutput = CalendarsGetOutput; +export const CalendarsUpdateInput = CalendarsCreateInput.partial().extend({ + id: z.string(), +}); +export const CalendarsUpdateOutput = CalendarsGetOutput; +export const CalendarsDeleteInput = IdInput; +export const CalendarsDeleteOutput = IdOutput; + +export const EventTrackingEventsListInput = PageInput; +export const EventTrackingEventsListOutput = listOf( + 'eventTrackingEvents', + ActiveCampaignEventTrackingEvent, +); +export const EventTrackingEventsCreateInput = z.object({ + name: z.string().min(1), +}); +export const EventTrackingEventsCreateOutput = oneOf( + 'eventTrackingEvent', + ActiveCampaignEventTrackingEvent, +); +export const EventTrackingEventsDeleteInput = IdInput; +export const EventTrackingEventsDeleteOutput = IdOutput; + +const TrackingStatus = z.object({ enabled: z.boolean() }); +export const TrackingGetSiteStatusInput = z.object({}); +export const TrackingGetSiteStatusOutput = z + .object({ siteTracking: TrackingStatus.loose() }) + .loose(); +export const TrackingGetEventStatusInput = z.object({}); +export const TrackingGetEventStatusOutput = z + .object({ eventTracking: TrackingStatus.loose() }) + .loose(); +export const TrackingSetSiteStatusInput = TrackingStatus; +export const TrackingSetSiteStatusOutput = TrackingGetSiteStatusOutput; +export const TrackingSetEventStatusInput = TrackingStatus; +export const TrackingSetEventStatusOutput = TrackingGetEventStatusOutput; + +/** + * Event tracking posts to a separate host with form encoding and needs the + * account's event key and actor id, neither of which is derivable from the API + * token, so both are caller-supplied. + */ +export const TrackingTrackEventInput = z.object({ + actid: z.string(), + key: z.string(), + event: z.string().min(1), + email: z.email(), + eventdata: z.string().optional(), +}); +export const TrackingTrackEventOutput = z.object({}).loose(); + +export const TrackingListWhitelistInput = PageInput; +export const TrackingListWhitelistOutput = z + .object({ + siteTrackingWhitelist: z.array(z.unknown()).optional(), + meta: Meta, + }) + .loose(); +export const TrackingAddWhitelistInput = z.object({ name: z.string().min(1) }); +export const TrackingAddWhitelistOutput = z.object({}).loose(); +export const TrackingRemoveWhitelistInput = IdInput; +export const TrackingRemoveWhitelistOutput = IdOutput; + +export const ScoresListInput = PageInput; +export const ScoresListOutput = listOf('scores', ActiveCampaignScore); + +export const EmailActivitiesListInput = z.object({ + ...PaginationInput, + subscriberid: z.string().optional(), + dealId: z.string().optional(), +}); +export const EmailActivitiesListOutput = z + .object({ emailActivities: z.array(z.unknown()), meta: Meta }) + .loose(); + +export const BrandingsGetInput = IdInput; +export const BrandingsGetOutput = oneOf('branding', ActiveCampaignBranding); +export const BrandingsUpdateInput = z.object({ + id: z.string(), + siteName: z.string().optional(), + siteLogo: z.string().optional(), + favicon: z.string().optional(), + copyright: z.string().optional(), +}); +export const BrandingsUpdateOutput = BrandingsGetOutput; +export const ConfigsUpdateInput = z.object({ + id: z.string(), + value: z.string(), +}); +export const ConfigsUpdateOutput = z.object({ config: z.unknown() }).loose(); + +// --- GraphQL e-commerce catalog -------------------------------------------- + +export const ProductsSearchInput = z.object({ + filter: z.record(z.string(), z.unknown()).optional(), + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().min(0).optional(), +}); +export const ProductsSearchOutput = z + .object({ products: z.array(z.unknown()).optional() }) + .loose(); +export const ProductsGetInput = IdInput; +export const ProductsGetOutput = z + .object({ product: z.unknown().optional() }) + .loose(); +export const ProductsCreateInput = z.object({ + legacyConnectionId: z.string(), + name: z.string().min(1), + sku: z.string().optional(), + price: z.number().optional(), + currency: z.string().optional(), + description: z.string().optional(), + imageUrl: z.url().optional(), + productUrl: z.url().optional(), + isVariant: z.boolean().optional(), +}); +export const ProductsCreateOutput = z + .object({ createProduct: z.unknown().optional() }) + .loose(); +export const ProductsUpdateInput = ProductsCreateInput.partial().extend({ + id: z.string(), +}); +export const ProductsUpdateOutput = z + .object({ updateProduct: z.unknown().optional() }) + .loose(); +export const ProductsDeleteInput = IdInput; +export const ProductsDeleteOutput = z + .object({ deleteProduct: z.unknown().optional() }) + .loose(); +export const ProductsUpsertBulkInput = z.object({ + products: z.array(z.record(z.string(), z.unknown())).min(1), +}); +export const ProductsUpsertBulkOutput = z.object({}).loose(); + +/** Orders are matched on storeOrderId within a connection. */ +export const OrdersUpsertBulkInput = z.object({ + orders: z.array(z.record(z.string(), z.unknown())).min(1), +}); +export const OrdersUpsertBulkOutput = z.object({}).loose(); +export const OrdersUpsertBulkAsyncInput = OrdersUpsertBulkInput; +export const OrdersUpsertBulkAsyncOutput = z.object({}).loose(); + +export const RecurringPaymentsSearchInput = ProductsSearchInput; +export const RecurringPaymentsSearchOutput = z + .object({ recurringPayments: z.array(z.unknown()).optional() }) + .loose(); +export const RecurringPaymentsUpsertBulkInput = z.object({ + recurringPayments: z.array(z.record(z.string(), z.unknown())).min(1), +}); +export const RecurringPaymentsUpsertBulkOutput = z.object({}).loose(); + +export const BrowseSessionsSearchInput = z.object({ + connectionId: z.string(), + email: z.email().optional(), + status: z.string().optional(), +}); +export const BrowseSessionsSearchOutput = z + .object({ browseSessions: z.array(z.unknown()).optional() }) + .loose(); +export const BrowseSessionsSaveInput = z.object({ + connectionId: z.string(), + email: z.email(), + status: z.string(), + products: z.array(z.record(z.string(), z.unknown())).optional(), +}); +export const BrowseSessionsSaveOutput = z.object({}).loose(); +export const BrowseSessionsAddToCartInput = z.object({ + connectionId: z.string(), + email: z.email(), +}); +export const BrowseSessionsAddToCartOutput = z.object({}).loose(); + +// --------------------------------------------------------------------------- +// SMS +// +// Reachable under `sms/*` rather than `smsBroadcasts`. Row shapes are left +// unmodelled: the development account returned an empty broadcast list, so +// nothing was captured to declare. +// --------------------------------------------------------------------------- + +export const SmsBroadcastsListInput = z.object({ + ...PaginationInput, + name: z.string().optional(), + status: z.string().optional(), +}); +export const SmsBroadcastsListOutput = z + .object({ broadcasts: z.array(z.unknown()).optional(), meta: Meta }) + .loose(); +export const SmsBroadcastsGetMetricsInput = z.object({ + broadcastIds: z.array(z.string()).optional(), +}); +export const SmsBroadcastsGetMetricsOutput = z.object({}).loose(); +export const SmsBroadcastsGetSnapshotInput = z.object({}); +export const SmsBroadcastsGetSnapshotOutput = z + .object({ snapshot: z.unknown().optional() }) + .loose(); +export const SmsBroadcastsCreateSnapshotInput = z.object({ + broadcastIds: z.array(z.string()).min(1), +}); +export const SmsBroadcastsCreateSnapshotOutput = z.object({}).loose(); +export const SmsBroadcastsGetFailuresInput = z.object({ + broadcastId: z.string(), + startDate: z.string().optional(), + endDate: z.string().optional(), +}); +export const SmsBroadcastsGetFailuresOutput = z.object({}).loose(); +export const SmsBroadcastsGetRecipientsInput = z.object({ + id: z.string(), + ...PaginationInput, +}); +export const SmsBroadcastsGetRecipientsOutput = z.object({}).loose(); +export const SmsCreditsGetInput = z.object({}); +export const SmsCreditsGetOutput = z + .object({ smsCredits: z.unknown().optional() }) + .loose(); + +export const TrackingGetCodeInput = z.object({}); +export const TrackingGetCodeOutput = z.object({}).loose(); + +// --------------------------------------------------------------------------- +// Late additions +// --------------------------------------------------------------------------- + +export const SmsBroadcastListsListInput = z.object({ + ...PaginationInput, + name: z.string().optional(), +}); +export const SmsBroadcastListsListOutput = z + .object({ lists: z.array(z.unknown()).optional(), meta: Meta }) + .loose(); + +export const AddressGroupsDeleteInput = IdInput; +export const AddressGroupsDeleteOutput = IdOutput; + +/** No route takes a store order id directly, so the collection is filtered. */ +export const EcomOrdersFindInput = z.object({ + connectionId: z.string(), + storeOrderId: z.string(), +}); +export const EcomOrdersFindOutput = z + .object({ ecomOrder: z.unknown().nullable() }) + .loose(); + +export const EcomOrdersUpsertInput = EcomOrdersCreateInput; +export const EcomOrdersUpsertOutput = EcomOrdersGetOutput; + +export const EcomOrderProductsListForOrderInput = z.object({ + orderId: z.string(), + ...PaginationInput, +}); +export const EcomOrderProductsListForOrderOutput = EcomOrderProductsListOutput; + +/** Typed note helpers: `/notes` with `reltype` fixed to the entity. */ +const TypedNoteCreate = z.object({ + id: z.string(), + note: z.string().min(1), +}); +export const NotesCreateForAccountInput = TypedNoteCreate; +export const NotesCreateForAccountOutput = NotesGetOutput; +export const NotesCreateForDealInput = TypedNoteCreate; +export const NotesCreateForDealOutput = NotesGetOutput; +export const NotesUpdateForAccountInput = NotesUpdateInput; +export const NotesUpdateForAccountOutput = NotesGetOutput; +export const NotesUpdateForDealInput = NotesUpdateInput; +export const NotesUpdateForDealOutput = NotesGetOutput; + +/** Contact tasks are deal tasks with `reltype: 'Subscriber'`. */ +export const ContactTasksCreateInput = z.object({ + contactId: z.string(), + title: z.string().min(1), + taskTypeId: z.string(), + dueDate: z.string(), + note: z.string().optional(), + assignee: z.string().optional(), +}); +export const ContactTasksCreateOutput = z + .object({ dealTask: ActiveCampaignDealTask }) + .loose(); +export const ContactTasksFindInput = z.object({ + title: z.string().min(1), + contactId: z.string().optional(), +}); +export const ContactTasksFindOutput = z + .object({ dealTasks: z.array(z.unknown()) }) + .loose(); + +// --------------------------------------------------------------------------- +// V2 segments, task reminders, child schemas, import aggregate, event test +// +// The V2 segment routes could not be confirmed against a live account - see +// the header of `endpoints/segments-v2.ts` and UNVERIFIED_ROUTES there. +// --------------------------------------------------------------------------- + +/** V2 segment ids are UUIDs, not the numeric ids the legacy API uses. */ +const SegmentUuid = z.string().min(1); + +export const SegmentsV2CreateInput = z.object({ + name: z.string().min(1), + description: z.string().optional(), + conditions: z.array(z.record(z.string(), z.unknown())).optional(), +}); +export const SegmentsV2CreateOutput = z + .object({ segment: z.unknown() }) + .loose(); +export const SegmentsV2GetInput = z.object({ id: SegmentUuid }); +export const SegmentsV2GetOutput = SegmentsV2CreateOutput; +export const SegmentsV2UpdateInput = SegmentsV2CreateInput.partial().extend({ + id: SegmentUuid, +}); +export const SegmentsV2UpdateOutput = SegmentsV2CreateOutput; +export const SegmentsV2DeleteInput = z.object({ id: SegmentUuid }); +/** Returns the segment's final state as an audit trail. */ +export const SegmentsV2DeleteOutput = SegmentsV2CreateOutput; + +export const SegmentsV2GetAtTimestampInput = z.object({ + id: SegmentUuid, + timestamp: z.string().min(1), +}); +export const SegmentsV2GetAtTimestampOutput = SegmentsV2CreateOutput; +export const SegmentsV2RevertToTimestampInput = SegmentsV2GetAtTimestampInput; +export const SegmentsV2RevertToTimestampOutput = SegmentsV2CreateOutput; + +export const SegmentsV2RecentCountsInput = PageInput; +export const SegmentsV2RecentCountsOutput = z + .object({ segmentCounts: z.array(z.unknown()).optional(), meta: Meta }) + .loose(); +export const SegmentsV2CountHistoryInput = z.object({ id: SegmentUuid }); +export const SegmentsV2CountHistoryOutput = SegmentsV2RecentCountsOutput; +export const SegmentsV2CountAtTimestampInput = SegmentsV2GetAtTimestampInput; +export const SegmentsV2CountAtTimestampOutput = SegmentsV2RecentCountsOutput; + +export const SegmentsV2MatchInput = z.object({ + id: SegmentUuid, + contactId: z.string(), +}); +export const SegmentsV2MatchOutput = z.object({}).loose(); +export const SegmentsV2MatchByExternalIdInput = z.object({ + id: SegmentUuid, + externalId: z.string().min(1), +}); +export const SegmentsV2MatchByExternalIdOutput = z.object({}).loose(); + +/** `is_ready: false` returns a run id to poll rather than a result set. */ +export const SegmentsV2MatchAllInput = z.object({ id: SegmentUuid }); +export const SegmentsV2MatchAllOutput = z + .object({ is_ready: z.boolean().optional(), run_id: z.unknown().optional() }) + .loose(); +export const SegmentsV2MatchAllResultInput = z.object({ runId: z.string() }); +export const SegmentsV2MatchAllResultOutput = SegmentsV2MatchAllOutput; +export const SegmentsV2MatchSomeResultInput = SegmentsV2MatchAllResultInput; +export const SegmentsV2MatchSomeResultOutput = SegmentsV2MatchAllOutput; + +/** `interval` is minutes before the task's due date. */ +export const TaskRemindersCreateInput = z.object({ + dealTask: z.string(), + interval: z.number().int().min(1), +}); +export const TaskRemindersCreateOutput = z + .object({ taskNotification: z.unknown() }) + .loose(); + +export const CustomObjectSchemasCreateChildInput = z.object({ + parentId: z.string(), + applicationId: z.string(), + slug: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), +}); +export const CustomObjectSchemasCreateChildOutput = + CustomObjectSchemasGetOutput; + +export const ImportsListAggregateInput = z.object({}); +export const ImportsListAggregateOutput = z.object({}).loose(); + +export const BrowseSessionsTestEventInput = z.object({ + connectionId: z.string(), + url: z.url(), + email: z.email().optional(), +}); +export const BrowseSessionsTestEventOutput = z.object({}).loose(); + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +export const ActiveCampaignEndpointInputSchemas = { + contactsList: ContactsListInput, + contactsGet: ContactsGetInput, + contactsFind: ContactsFindInput, + contactsCreateOrUpdate: ContactsCreateOrUpdateInput, + contactsUpdate: ContactsUpdateInput, + contactsDelete: ContactsDeleteInput, + contactsGetLists: ContactsGetListsInput, + contactsGetTags: ContactsGetTagsInput, + contactsGetFieldValues: ContactsGetFieldValuesInput, + contactsGetAutomations: ContactsGetAutomationsInput, + contactsGetGeoIps: ContactsGetGeoIpsInput, + contactsGetScoreValues: ContactsGetScoreValuesInput, + contactsGetDeals: ContactsGetDealsInput, + listsList: ListsListInput, + listsGet: ListsGetInput, + listsCreate: ListsCreateInput, + listsDelete: ListsDeleteInput, + listsUpdateSubscription: ListsUpdateSubscriptionInput, + contactListsList: ContactListsListInput, + tagsList: TagsListInput, + tagsGet: TagsGetInput, + tagsCreate: TagsCreateInput, + tagsUpdate: TagsUpdateInput, + tagsDelete: TagsDeleteInput, + tagsAddToContact: TagsAddToContactInput, + tagsRemoveFromContact: TagsRemoveFromContactInput, + contactTagsList: ContactTagsListInput, + fieldsList: FieldsListInput, + fieldsGet: FieldsGetInput, + fieldsCreate: FieldsCreateInput, + fieldsUpdate: FieldsUpdateInput, + fieldsDelete: FieldsDeleteInput, + fieldOptionsCreateBulk: FieldOptionsCreateBulkInput, + fieldValuesList: FieldValuesListInput, + fieldValuesGet: FieldValuesGetInput, + fieldValuesSetForContact: FieldValuesSetForContactInput, + fieldValuesUpdate: FieldValuesUpdateInput, + fieldValuesDelete: FieldValuesDeleteInput, + fieldRelsList: FieldRelsListInput, + fieldRelsCreate: FieldRelsCreateInput, + fieldRelsDelete: FieldRelsDeleteInput, + groupMembersList: GroupMembersListInput, + groupMembersCreate: GroupMembersCreateInput, + groupMembersUpdate: GroupMembersUpdateInput, + groupMembersDelete: GroupMembersDeleteInput, + contactsGetLogs: ContactsGetLogsInput, + contactsGetTrackingLogs: ContactsGetTrackingLogsInput, + contactsGetGoals: ContactsGetGoalsInput, + contactsGetAccountContacts: ContactsGetAccountContactsInput, + contactsGetNotes: ContactsGetNotesInput, + contactsGetData: ContactsGetDataInput, + contactsGetOrganization: ContactsGetOrganizationInput, + contactsGetPlusAppend: ContactsGetPlusAppendInput, + activitiesList: ActivitiesListInput, + importsCreateBulk: ImportsCreateBulkInput, + importsList: ImportsListInput, + importsGetStatus: ImportsGetStatusInput, + listGroupsCreate: ListGroupsCreateInput, + dealsList: DealsListInput, + dealsListFiltered: DealsListInput, + dealsGet: DealsGetInput, + dealsUpdate: DealsUpdateInput, + dealsDelete: DealsDeleteInput, + dealsUpdateOwnersBulk: DealsUpdateOwnersBulkInput, + dealGroupsList: DealGroupsListInput, + dealGroupsGet: DealGroupsGetInput, + dealGroupsCreate: DealGroupsCreateInput, + dealGroupsUpdate: DealGroupsUpdateInput, + dealGroupsDelete: DealGroupsDeleteInput, + dealStagesList: DealStagesListInput, + dealStagesGet: DealStagesGetInput, + dealStagesCreate: DealStagesCreateInput, + dealStagesUpdate: DealStagesUpdateInput, + dealStagesDelete: DealStagesDeleteInput, + dealStagesMoveDeals: DealStagesMoveDealsInput, + dealStagesDeleteWithDeals: DealStagesDeleteWithDealsInput, + dealTasksList: DealTasksListInput, + dealTasksGet: DealTasksGetInput, + dealTasksCreate: DealTasksCreateInput, + dealTasksUpdate: DealTasksUpdateInput, + dealTasksDelete: DealTasksDeleteInput, + dealTaskTypesList: DealTaskTypesListInput, + dealTaskTypesGet: DealTaskTypesGetInput, + dealTaskTypesCreate: DealTaskTypesCreateInput, + dealTaskTypesUpdate: DealTaskTypesUpdateInput, + taskOutcomesList: TaskOutcomesListInput, + taskOutcomesGet: TaskOutcomesGetInput, + taskOutcomesCreate: TaskOutcomesCreateInput, + dealRolesList: DealRolesListInput, + dealRolesCreate: DealRolesCreateInput, + dealRolesDelete: DealRolesDeleteInput, + contactDealsList: ContactDealsListInput, + contactDealsGet: ContactDealsGetInput, + contactDealsCreate: ContactDealsCreateInput, + contactDealsUpdate: ContactDealsUpdateInput, + contactDealsDelete: ContactDealsDeleteInput, + dealCustomFieldMetaList: DealCustomFieldMetaListInput, + dealCustomFieldMetaGet: DealCustomFieldMetaGetInput, + dealCustomFieldMetaCreate: DealCustomFieldMetaCreateInput, + dealCustomFieldMetaUpdate: DealCustomFieldMetaUpdateInput, + dealCustomFieldMetaDelete: DealCustomFieldMetaDeleteInput, + dealCustomFieldDataList: DealCustomFieldDataListInput, + dealCustomFieldDataGet: DealCustomFieldDataGetInput, + dealCustomFieldDataUpdate: DealCustomFieldDataUpdateInput, + dealCustomFieldDataDelete: DealCustomFieldDataDeleteInput, + dealActivitiesList: DealActivitiesListInput, + accountsList: AccountsListInput, + accountsGet: AccountsGetInput, + accountsCreate: AccountsCreateInput, + accountsUpdate: AccountsUpdateInput, + accountsDelete: AccountsDeleteInput, + accountsUpsert: AccountsUpsertInput, + accountsDeleteBulk: AccountsDeleteBulkInput, + accountContactsList: AccountContactsListInput, + accountContactsGet: AccountContactsGetInput, + accountContactsCreate: AccountContactsCreateInput, + accountContactsUpdate: AccountContactsUpdateInput, + accountContactsDelete: AccountContactsDeleteInput, + accountCustomFieldMetaList: AccountCustomFieldMetaListInput, + accountCustomFieldMetaGet: AccountCustomFieldMetaGetInput, + accountCustomFieldMetaCreate: AccountCustomFieldMetaCreateInput, + accountCustomFieldMetaUpdate: AccountCustomFieldMetaUpdateInput, + accountCustomFieldMetaDelete: AccountCustomFieldMetaDeleteInput, + accountCustomFieldDataList: AccountCustomFieldDataListInput, + accountCustomFieldDataGet: AccountCustomFieldDataGetInput, + accountCustomFieldDataCreate: AccountCustomFieldDataCreateInput, + accountCustomFieldDataUpdate: AccountCustomFieldDataUpdateInput, + accountCustomFieldDataDelete: AccountCustomFieldDataDeleteInput, + accountCustomFieldDataCreateBulk: AccountCustomFieldDataCreateBulkInput, + accountCustomFieldDataUpdateBulk: AccountCustomFieldDataUpdateBulkInput, + notesList: NotesListInput, + notesGet: NotesGetInput, + notesCreate: NotesCreateInput, + notesUpdate: NotesUpdateInput, + notesDelete: NotesDeleteInput, + notesAddToContact: NotesAddToContactInput, + campaignsList: CampaignsListInput, + campaignsGet: CampaignsGetInput, + campaignsCreate: CampaignsCreateInput, + campaignsUpdate: CampaignsUpdateInput, + campaignsDuplicate: CampaignsDuplicateInput, + campaignsGetLinks: CampaignsGetLinksInput, + campaignsGetMessages: CampaignsGetMessagesInput, + campaignsGetAutomations: CampaignsGetAutomationsInput, + campaignsGetAutomationLists: CampaignsGetAutomationListsInput, + campaignsGetUser: CampaignsGetUserInput, + messagesList: MessagesListInput, + messagesGet: MessagesGetInput, + messagesCreate: MessagesCreateInput, + messagesUpdate: MessagesUpdateInput, + messagesDelete: MessagesDeleteInput, + savedResponsesList: SavedResponsesListInput, + savedResponsesGet: SavedResponsesGetInput, + savedResponsesCreate: SavedResponsesCreateInput, + savedResponsesUpdate: SavedResponsesUpdateInput, + savedResponsesDelete: SavedResponsesDeleteInput, + formsList: FormsListInput, + formsGet: FormsGetInput, + formsDelete: FormsDeleteInput, + formsCreateOptin: FormsCreateOptinInput, + personalizationsList: PersonalizationsListInput, + personalizationsGet: PersonalizationsGetInput, + personalizationsCreate: PersonalizationsCreateInput, + personalizationsUpdate: PersonalizationsUpdateInput, + personalizationsDelete: PersonalizationsDeleteInput, + personalizationsDeleteBulk: PersonalizationsDeleteBulkInput, + personalizationsLock: PersonalizationsLockInput, + personalizationsUnlock: PersonalizationsUnlockInput, + templatesGet: TemplatesGetInput, + templatesCreateShareLink: TemplatesCreateShareLinkInput, + automationsList: AutomationsListInput, + contactAutomationsList: ContactAutomationsListInput, + contactAutomationsGet: ContactAutomationsGetInput, + contactAutomationsEntryCounts: ContactAutomationsEntryCountsInput, + contactAutomationsAdd: ContactAutomationsAddInput, + contactAutomationsRemove: ContactAutomationsRemoveInput, + segmentsList: SegmentsListInput, + segmentsGet: SegmentsGetInput, + segmentsCreate: SegmentsCreateInput, + segmentsUpdate: SegmentsUpdateInput, + segmentsDelete: SegmentsDeleteInput, + segmentsListAudiences: SegmentsListAudiencesInput, + connectionsList: ConnectionsListInput, + connectionsGet: ConnectionsGetInput, + connectionsCreate: ConnectionsCreateInput, + connectionsUpdate: ConnectionsUpdateInput, + connectionsDelete: ConnectionsDeleteInput, + ecomCustomersList: EcomCustomersListInput, + ecomCustomersGet: EcomCustomersGetInput, + ecomCustomersCreate: EcomCustomersCreateInput, + ecomCustomersUpdate: EcomCustomersUpdateInput, + ecomCustomersDelete: EcomCustomersDeleteInput, + ecomOrdersList: EcomOrdersListInput, + ecomOrdersGet: EcomOrdersGetInput, + ecomOrdersCreate: EcomOrdersCreateInput, + ecomOrdersUpdate: EcomOrdersUpdateInput, + ecomOrdersDelete: EcomOrdersDeleteInput, + ecomOrderProductsList: EcomOrderProductsListInput, + ecomOrderProductsGet: EcomOrderProductsGetInput, + customObjectSchemasList: CustomObjectSchemasListInput, + customObjectSchemasGet: CustomObjectSchemasGetInput, + customObjectSchemasCreate: CustomObjectSchemasCreateInput, + customObjectSchemasUpdate: CustomObjectSchemasUpdateInput, + customObjectSchemasDelete: CustomObjectSchemasDeleteInput, + customObjectRecordsList: CustomObjectRecordsListInput, + customObjectRecordsUpsert: CustomObjectRecordsUpsertInput, + customObjectRecordsGet: CustomObjectRecordsGetInput, + customObjectRecordsGetByExternalId: CustomObjectRecordsGetByExternalIdInput, + customObjectRecordsDelete: CustomObjectRecordsDeleteInput, + customObjectRecordsDeleteByExternalId: + CustomObjectRecordsDeleteByExternalIdInput, + webhooksList: WebhooksListInput, + webhooksGet: WebhooksGetInput, + webhooksCreate: WebhooksCreateInput, + webhooksUpdate: WebhooksUpdateInput, + webhooksDelete: WebhooksDeleteInput, + usersList: UsersListInput, + usersGet: UsersGetInput, + usersCreate: UsersCreateInput, + usersUpdate: UsersUpdateInput, + usersDelete: UsersDeleteInput, + usersGetMe: UsersGetMeInput, + usersGetByUsername: UsersGetByUsernameInput, + groupsList: GroupsListInput, + groupsGet: GroupsGetInput, + groupsCreate: GroupsCreateInput, + groupsUpdate: GroupsUpdateInput, + groupsDelete: GroupsDeleteInput, + groupLimitsList: GroupLimitsListInput, + addressesList: AddressesListInput, + addressesGet: AddressesGetInput, + addressesCreate: AddressesCreateInput, + addressesUpdate: AddressesUpdateInput, + addressesDelete: AddressesDeleteInput, + calendarsList: CalendarsListInput, + calendarsGet: CalendarsGetInput, + calendarsCreate: CalendarsCreateInput, + calendarsUpdate: CalendarsUpdateInput, + calendarsDelete: CalendarsDeleteInput, + eventTrackingEventsList: EventTrackingEventsListInput, + eventTrackingEventsCreate: EventTrackingEventsCreateInput, + eventTrackingEventsDelete: EventTrackingEventsDeleteInput, + trackingGetSiteStatus: TrackingGetSiteStatusInput, + trackingGetEventStatus: TrackingGetEventStatusInput, + trackingSetSiteStatus: TrackingSetSiteStatusInput, + trackingSetEventStatus: TrackingSetEventStatusInput, + trackingTrackEvent: TrackingTrackEventInput, + trackingListWhitelist: TrackingListWhitelistInput, + trackingAddWhitelist: TrackingAddWhitelistInput, + trackingRemoveWhitelist: TrackingRemoveWhitelistInput, + scoresList: ScoresListInput, + emailActivitiesList: EmailActivitiesListInput, + brandingsGet: BrandingsGetInput, + brandingsUpdate: BrandingsUpdateInput, + configsUpdate: ConfigsUpdateInput, + productsSearch: ProductsSearchInput, + productsGet: ProductsGetInput, + productsCreate: ProductsCreateInput, + productsUpdate: ProductsUpdateInput, + productsDelete: ProductsDeleteInput, + productsUpsertBulk: ProductsUpsertBulkInput, + ordersUpsertBulk: OrdersUpsertBulkInput, + ordersUpsertBulkAsync: OrdersUpsertBulkAsyncInput, + recurringPaymentsSearch: RecurringPaymentsSearchInput, + recurringPaymentsUpsertBulk: RecurringPaymentsUpsertBulkInput, + browseSessionsSearch: BrowseSessionsSearchInput, + browseSessionsSave: BrowseSessionsSaveInput, + browseSessionsAddToCart: BrowseSessionsAddToCartInput, + smsBroadcastsList: SmsBroadcastsListInput, + smsBroadcastsGetMetrics: SmsBroadcastsGetMetricsInput, + smsBroadcastsGetSnapshot: SmsBroadcastsGetSnapshotInput, + smsBroadcastsCreateSnapshot: SmsBroadcastsCreateSnapshotInput, + smsBroadcastsGetFailures: SmsBroadcastsGetFailuresInput, + smsBroadcastsGetRecipients: SmsBroadcastsGetRecipientsInput, + smsCreditsGet: SmsCreditsGetInput, + trackingGetCode: TrackingGetCodeInput, + smsBroadcastListsList: SmsBroadcastListsListInput, + addressGroupsDelete: AddressGroupsDeleteInput, + ecomOrdersFind: EcomOrdersFindInput, + ecomOrdersUpsert: EcomOrdersUpsertInput, + ecomOrderProductsListForOrder: EcomOrderProductsListForOrderInput, + notesCreateForAccount: NotesCreateForAccountInput, + notesCreateForDeal: NotesCreateForDealInput, + notesUpdateForAccount: NotesUpdateForAccountInput, + notesUpdateForDeal: NotesUpdateForDealInput, + contactTasksCreate: ContactTasksCreateInput, + contactTasksFind: ContactTasksFindInput, + segmentsV2Create: SegmentsV2CreateInput, + segmentsV2Get: SegmentsV2GetInput, + segmentsV2Update: SegmentsV2UpdateInput, + segmentsV2Delete: SegmentsV2DeleteInput, + segmentsV2GetAtTimestamp: SegmentsV2GetAtTimestampInput, + segmentsV2RevertToTimestamp: SegmentsV2RevertToTimestampInput, + segmentsV2RecentCounts: SegmentsV2RecentCountsInput, + segmentsV2CountHistory: SegmentsV2CountHistoryInput, + segmentsV2CountAtTimestamp: SegmentsV2CountAtTimestampInput, + segmentsV2Match: SegmentsV2MatchInput, + segmentsV2MatchByExternalId: SegmentsV2MatchByExternalIdInput, + segmentsV2MatchAll: SegmentsV2MatchAllInput, + segmentsV2MatchAllResult: SegmentsV2MatchAllResultInput, + segmentsV2MatchSomeResult: SegmentsV2MatchSomeResultInput, + taskRemindersCreate: TaskRemindersCreateInput, + customObjectSchemasCreateChild: CustomObjectSchemasCreateChildInput, + importsListAggregate: ImportsListAggregateInput, + browseSessionsTestEvent: BrowseSessionsTestEventInput, +} as const; + +export const ActiveCampaignEndpointOutputSchemas = { + contactsList: ContactsListOutput, + contactsGet: ContactsGetOutput, + contactsFind: ContactsFindOutput, + contactsCreateOrUpdate: ContactsCreateOrUpdateOutput, + contactsUpdate: ContactsUpdateOutput, + contactsDelete: ContactsDeleteOutput, + contactsGetLists: ContactsGetListsOutput, + contactsGetTags: ContactsGetTagsOutput, + contactsGetFieldValues: ContactsGetFieldValuesOutput, + contactsGetAutomations: ContactsGetAutomationsOutput, + contactsGetGeoIps: ContactsGetGeoIpsOutput, + contactsGetScoreValues: ContactsGetScoreValuesOutput, + contactsGetDeals: ContactsGetDealsOutput, + listsList: ListsListOutput, + listsGet: ListsGetOutput, + listsCreate: ListsCreateOutput, + listsDelete: ListsDeleteOutput, + listsUpdateSubscription: ListsUpdateSubscriptionOutput, + contactListsList: ContactListsListOutput, + tagsList: TagsListOutput, + tagsGet: TagsGetOutput, + tagsCreate: TagsCreateOutput, + tagsUpdate: TagsUpdateOutput, + tagsDelete: TagsDeleteOutput, + tagsAddToContact: TagsAddToContactOutput, + tagsRemoveFromContact: TagsRemoveFromContactOutput, + contactTagsList: ContactTagsListOutput, + fieldsList: FieldsListOutput, + fieldsGet: FieldsGetOutput, + fieldsCreate: FieldsCreateOutput, + fieldsUpdate: FieldsUpdateOutput, + fieldsDelete: FieldsDeleteOutput, + fieldOptionsCreateBulk: FieldOptionsCreateBulkOutput, + fieldValuesList: FieldValuesListOutput, + fieldValuesGet: FieldValuesGetOutput, + fieldValuesSetForContact: FieldValuesSetForContactOutput, + fieldValuesUpdate: FieldValuesUpdateOutput, + fieldValuesDelete: FieldValuesDeleteOutput, + fieldRelsList: FieldRelsListOutput, + fieldRelsCreate: FieldRelsCreateOutput, + fieldRelsDelete: FieldRelsDeleteOutput, + groupMembersList: GroupMembersListOutput, + groupMembersCreate: GroupMembersCreateOutput, + groupMembersUpdate: GroupMembersUpdateOutput, + groupMembersDelete: GroupMembersDeleteOutput, + contactsGetLogs: ContactsGetLogsOutput, + contactsGetTrackingLogs: ContactsGetTrackingLogsOutput, + contactsGetGoals: ContactsGetGoalsOutput, + contactsGetAccountContacts: ContactsGetAccountContactsOutput, + contactsGetNotes: ContactsGetNotesOutput, + contactsGetData: ContactsGetDataOutput, + contactsGetOrganization: ContactsGetOrganizationOutput, + contactsGetPlusAppend: ContactsGetPlusAppendOutput, + activitiesList: ActivitiesListOutput, + importsCreateBulk: ImportsCreateBulkOutput, + importsList: ImportsListOutput, + importsGetStatus: ImportsGetStatusOutput, + listGroupsCreate: ListGroupsCreateOutput, + dealsList: DealsListOutput, + dealsListFiltered: DealsListOutput, + dealsGet: DealsGetOutput, + dealsUpdate: DealsUpdateOutput, + dealsDelete: DealsDeleteOutput, + dealsUpdateOwnersBulk: DealsUpdateOwnersBulkOutput, + dealGroupsList: DealGroupsListOutput, + dealGroupsGet: DealGroupsGetOutput, + dealGroupsCreate: DealGroupsCreateOutput, + dealGroupsUpdate: DealGroupsUpdateOutput, + dealGroupsDelete: DealGroupsDeleteOutput, + dealStagesList: DealStagesListOutput, + dealStagesGet: DealStagesGetOutput, + dealStagesCreate: DealStagesCreateOutput, + dealStagesUpdate: DealStagesUpdateOutput, + dealStagesDelete: DealStagesDeleteOutput, + dealStagesMoveDeals: DealStagesMoveDealsOutput, + dealStagesDeleteWithDeals: DealStagesDeleteWithDealsOutput, + dealTasksList: DealTasksListOutput, + dealTasksGet: DealTasksGetOutput, + dealTasksCreate: DealTasksCreateOutput, + dealTasksUpdate: DealTasksUpdateOutput, + dealTasksDelete: DealTasksDeleteOutput, + dealTaskTypesList: DealTaskTypesListOutput, + dealTaskTypesGet: DealTaskTypesGetOutput, + dealTaskTypesCreate: DealTaskTypesCreateOutput, + dealTaskTypesUpdate: DealTaskTypesUpdateOutput, + taskOutcomesList: TaskOutcomesListOutput, + taskOutcomesGet: TaskOutcomesGetOutput, + taskOutcomesCreate: TaskOutcomesCreateOutput, + dealRolesList: DealRolesListOutput, + dealRolesCreate: DealRolesCreateOutput, + dealRolesDelete: DealRolesDeleteOutput, + contactDealsList: ContactDealsListOutput, + contactDealsGet: ContactDealsGetOutput, + contactDealsCreate: ContactDealsCreateOutput, + contactDealsUpdate: ContactDealsUpdateOutput, + contactDealsDelete: ContactDealsDeleteOutput, + dealCustomFieldMetaList: DealCustomFieldMetaListOutput, + dealCustomFieldMetaGet: DealCustomFieldMetaGetOutput, + dealCustomFieldMetaCreate: DealCustomFieldMetaCreateOutput, + dealCustomFieldMetaUpdate: DealCustomFieldMetaUpdateOutput, + dealCustomFieldMetaDelete: DealCustomFieldMetaDeleteOutput, + dealCustomFieldDataList: DealCustomFieldDataListOutput, + dealCustomFieldDataGet: DealCustomFieldDataGetOutput, + dealCustomFieldDataUpdate: DealCustomFieldDataUpdateOutput, + dealCustomFieldDataDelete: DealCustomFieldDataDeleteOutput, + dealActivitiesList: DealActivitiesListOutput, + accountsList: AccountsListOutput, + accountsGet: AccountsGetOutput, + accountsCreate: AccountsCreateOutput, + accountsUpdate: AccountsUpdateOutput, + accountsDelete: AccountsDeleteOutput, + accountsUpsert: AccountsUpsertOutput, + accountsDeleteBulk: AccountsDeleteBulkOutput, + accountContactsList: AccountContactsListOutput, + accountContactsGet: AccountContactsGetOutput, + accountContactsCreate: AccountContactsCreateOutput, + accountContactsUpdate: AccountContactsUpdateOutput, + accountContactsDelete: AccountContactsDeleteOutput, + accountCustomFieldMetaList: AccountCustomFieldMetaListOutput, + accountCustomFieldMetaGet: AccountCustomFieldMetaGetOutput, + accountCustomFieldMetaCreate: AccountCustomFieldMetaCreateOutput, + accountCustomFieldMetaUpdate: AccountCustomFieldMetaUpdateOutput, + accountCustomFieldMetaDelete: AccountCustomFieldMetaDeleteOutput, + accountCustomFieldDataList: AccountCustomFieldDataListOutput, + accountCustomFieldDataGet: AccountCustomFieldDataGetOutput, + accountCustomFieldDataCreate: AccountCustomFieldDataCreateOutput, + accountCustomFieldDataUpdate: AccountCustomFieldDataUpdateOutput, + accountCustomFieldDataDelete: AccountCustomFieldDataDeleteOutput, + accountCustomFieldDataCreateBulk: AccountCustomFieldDataCreateBulkOutput, + accountCustomFieldDataUpdateBulk: AccountCustomFieldDataUpdateBulkOutput, + notesList: NotesListOutput, + notesGet: NotesGetOutput, + notesCreate: NotesCreateOutput, + notesUpdate: NotesUpdateOutput, + notesDelete: NotesDeleteOutput, + notesAddToContact: NotesAddToContactOutput, + campaignsList: CampaignsListOutput, + campaignsGet: CampaignsGetOutput, + campaignsCreate: CampaignsCreateOutput, + campaignsUpdate: CampaignsUpdateOutput, + campaignsDuplicate: CampaignsDuplicateOutput, + campaignsGetLinks: CampaignsGetLinksOutput, + campaignsGetMessages: CampaignsGetMessagesOutput, + campaignsGetAutomations: CampaignsGetAutomationsOutput, + campaignsGetAutomationLists: CampaignsGetAutomationListsOutput, + campaignsGetUser: CampaignsGetUserOutput, + messagesList: MessagesListOutput, + messagesGet: MessagesGetOutput, + messagesCreate: MessagesCreateOutput, + messagesUpdate: MessagesUpdateOutput, + messagesDelete: MessagesDeleteOutput, + savedResponsesList: SavedResponsesListOutput, + savedResponsesGet: SavedResponsesGetOutput, + savedResponsesCreate: SavedResponsesCreateOutput, + savedResponsesUpdate: SavedResponsesUpdateOutput, + savedResponsesDelete: SavedResponsesDeleteOutput, + formsList: FormsListOutput, + formsGet: FormsGetOutput, + formsDelete: FormsDeleteOutput, + formsCreateOptin: FormsCreateOptinOutput, + personalizationsList: PersonalizationsListOutput, + personalizationsGet: PersonalizationsGetOutput, + personalizationsCreate: PersonalizationsCreateOutput, + personalizationsUpdate: PersonalizationsUpdateOutput, + personalizationsDelete: PersonalizationsDeleteOutput, + personalizationsDeleteBulk: PersonalizationsDeleteBulkOutput, + personalizationsLock: PersonalizationsLockOutput, + personalizationsUnlock: PersonalizationsUnlockOutput, + templatesGet: TemplatesGetOutput, + templatesCreateShareLink: TemplatesCreateShareLinkOutput, + automationsList: AutomationsListOutput, + contactAutomationsList: ContactAutomationsListOutput, + contactAutomationsGet: ContactAutomationsGetOutput, + contactAutomationsEntryCounts: ContactAutomationsEntryCountsOutput, + contactAutomationsAdd: ContactAutomationsAddOutput, + contactAutomationsRemove: ContactAutomationsRemoveOutput, + segmentsList: SegmentsListOutput, + segmentsGet: SegmentsGetOutput, + segmentsCreate: SegmentsCreateOutput, + segmentsUpdate: SegmentsUpdateOutput, + segmentsDelete: SegmentsDeleteOutput, + segmentsListAudiences: SegmentsListAudiencesOutput, + connectionsList: ConnectionsListOutput, + connectionsGet: ConnectionsGetOutput, + connectionsCreate: ConnectionsCreateOutput, + connectionsUpdate: ConnectionsUpdateOutput, + connectionsDelete: ConnectionsDeleteOutput, + ecomCustomersList: EcomCustomersListOutput, + ecomCustomersGet: EcomCustomersGetOutput, + ecomCustomersCreate: EcomCustomersCreateOutput, + ecomCustomersUpdate: EcomCustomersUpdateOutput, + ecomCustomersDelete: EcomCustomersDeleteOutput, + ecomOrdersList: EcomOrdersListOutput, + ecomOrdersGet: EcomOrdersGetOutput, + ecomOrdersCreate: EcomOrdersCreateOutput, + ecomOrdersUpdate: EcomOrdersUpdateOutput, + ecomOrdersDelete: EcomOrdersDeleteOutput, + ecomOrderProductsList: EcomOrderProductsListOutput, + ecomOrderProductsGet: EcomOrderProductsGetOutput, + customObjectSchemasList: CustomObjectSchemasListOutput, + customObjectSchemasGet: CustomObjectSchemasGetOutput, + customObjectSchemasCreate: CustomObjectSchemasCreateOutput, + customObjectSchemasUpdate: CustomObjectSchemasUpdateOutput, + customObjectSchemasDelete: CustomObjectSchemasDeleteOutput, + customObjectRecordsList: CustomObjectRecordsListOutput, + customObjectRecordsUpsert: CustomObjectRecordsUpsertOutput, + customObjectRecordsGet: CustomObjectRecordsGetOutput, + customObjectRecordsGetByExternalId: CustomObjectRecordsGetByExternalIdOutput, + customObjectRecordsDelete: CustomObjectRecordsDeleteOutput, + customObjectRecordsDeleteByExternalId: + CustomObjectRecordsDeleteByExternalIdOutput, + webhooksList: WebhooksListOutput, + webhooksGet: WebhooksGetOutput, + webhooksCreate: WebhooksCreateOutput, + webhooksUpdate: WebhooksUpdateOutput, + webhooksDelete: WebhooksDeleteOutput, + usersList: UsersListOutput, + usersGet: UsersGetOutput, + usersCreate: UsersCreateOutput, + usersUpdate: UsersUpdateOutput, + usersDelete: UsersDeleteOutput, + usersGetMe: UsersGetMeOutput, + usersGetByUsername: UsersGetByUsernameOutput, + groupsList: GroupsListOutput, + groupsGet: GroupsGetOutput, + groupsCreate: GroupsCreateOutput, + groupsUpdate: GroupsUpdateOutput, + groupsDelete: GroupsDeleteOutput, + groupLimitsList: GroupLimitsListOutput, + addressesList: AddressesListOutput, + addressesGet: AddressesGetOutput, + addressesCreate: AddressesCreateOutput, + addressesUpdate: AddressesUpdateOutput, + addressesDelete: AddressesDeleteOutput, + calendarsList: CalendarsListOutput, + calendarsGet: CalendarsGetOutput, + calendarsCreate: CalendarsCreateOutput, + calendarsUpdate: CalendarsUpdateOutput, + calendarsDelete: CalendarsDeleteOutput, + eventTrackingEventsList: EventTrackingEventsListOutput, + eventTrackingEventsCreate: EventTrackingEventsCreateOutput, + eventTrackingEventsDelete: EventTrackingEventsDeleteOutput, + trackingGetSiteStatus: TrackingGetSiteStatusOutput, + trackingGetEventStatus: TrackingGetEventStatusOutput, + trackingSetSiteStatus: TrackingSetSiteStatusOutput, + trackingSetEventStatus: TrackingSetEventStatusOutput, + trackingTrackEvent: TrackingTrackEventOutput, + trackingListWhitelist: TrackingListWhitelistOutput, + trackingAddWhitelist: TrackingAddWhitelistOutput, + trackingRemoveWhitelist: TrackingRemoveWhitelistOutput, + scoresList: ScoresListOutput, + emailActivitiesList: EmailActivitiesListOutput, + brandingsGet: BrandingsGetOutput, + brandingsUpdate: BrandingsUpdateOutput, + configsUpdate: ConfigsUpdateOutput, + productsSearch: ProductsSearchOutput, + productsGet: ProductsGetOutput, + productsCreate: ProductsCreateOutput, + productsUpdate: ProductsUpdateOutput, + productsDelete: ProductsDeleteOutput, + productsUpsertBulk: ProductsUpsertBulkOutput, + ordersUpsertBulk: OrdersUpsertBulkOutput, + ordersUpsertBulkAsync: OrdersUpsertBulkAsyncOutput, + recurringPaymentsSearch: RecurringPaymentsSearchOutput, + recurringPaymentsUpsertBulk: RecurringPaymentsUpsertBulkOutput, + browseSessionsSearch: BrowseSessionsSearchOutput, + browseSessionsSave: BrowseSessionsSaveOutput, + browseSessionsAddToCart: BrowseSessionsAddToCartOutput, + smsBroadcastsList: SmsBroadcastsListOutput, + smsBroadcastsGetMetrics: SmsBroadcastsGetMetricsOutput, + smsBroadcastsGetSnapshot: SmsBroadcastsGetSnapshotOutput, + smsBroadcastsCreateSnapshot: SmsBroadcastsCreateSnapshotOutput, + smsBroadcastsGetFailures: SmsBroadcastsGetFailuresOutput, + smsBroadcastsGetRecipients: SmsBroadcastsGetRecipientsOutput, + smsCreditsGet: SmsCreditsGetOutput, + trackingGetCode: TrackingGetCodeOutput, + smsBroadcastListsList: SmsBroadcastListsListOutput, + addressGroupsDelete: AddressGroupsDeleteOutput, + ecomOrdersFind: EcomOrdersFindOutput, + ecomOrdersUpsert: EcomOrdersUpsertOutput, + ecomOrderProductsListForOrder: EcomOrderProductsListForOrderOutput, + notesCreateForAccount: NotesCreateForAccountOutput, + notesCreateForDeal: NotesCreateForDealOutput, + notesUpdateForAccount: NotesUpdateForAccountOutput, + notesUpdateForDeal: NotesUpdateForDealOutput, + contactTasksCreate: ContactTasksCreateOutput, + contactTasksFind: ContactTasksFindOutput, + segmentsV2Create: SegmentsV2CreateOutput, + segmentsV2Get: SegmentsV2GetOutput, + segmentsV2Update: SegmentsV2UpdateOutput, + segmentsV2Delete: SegmentsV2DeleteOutput, + segmentsV2GetAtTimestamp: SegmentsV2GetAtTimestampOutput, + segmentsV2RevertToTimestamp: SegmentsV2RevertToTimestampOutput, + segmentsV2RecentCounts: SegmentsV2RecentCountsOutput, + segmentsV2CountHistory: SegmentsV2CountHistoryOutput, + segmentsV2CountAtTimestamp: SegmentsV2CountAtTimestampOutput, + segmentsV2Match: SegmentsV2MatchOutput, + segmentsV2MatchByExternalId: SegmentsV2MatchByExternalIdOutput, + segmentsV2MatchAll: SegmentsV2MatchAllOutput, + segmentsV2MatchAllResult: SegmentsV2MatchAllResultOutput, + segmentsV2MatchSomeResult: SegmentsV2MatchSomeResultOutput, + taskRemindersCreate: TaskRemindersCreateOutput, + customObjectSchemasCreateChild: CustomObjectSchemasCreateChildOutput, + importsListAggregate: ImportsListAggregateOutput, + browseSessionsTestEvent: BrowseSessionsTestEventOutput, +} as const; + +export type ActiveCampaignEndpointInputs = { + [K in keyof typeof ActiveCampaignEndpointInputSchemas]: z.infer< + (typeof ActiveCampaignEndpointInputSchemas)[K] + >; +}; + +export type ActiveCampaignEndpointOutputs = { + [K in keyof typeof ActiveCampaignEndpointOutputSchemas]: z.infer< + (typeof ActiveCampaignEndpointOutputSchemas)[K] + >; +}; diff --git a/packages/activecampaign/error-handlers.ts b/packages/activecampaign/error-handlers.ts new file mode 100644 index 000000000..d6daff4a3 --- /dev/null +++ b/packages/activecampaign/error-handlers.ts @@ -0,0 +1,333 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +/** + * Operations that change server state. + * + * Corsair replays the whole endpoint call when a handler asks for a retry, so + * a network error raised after ActiveCampaign already committed a write would + * duplicate it on the next attempt. ActiveCampaign has no idempotency-key + * header, so a duplicate cannot be collapsed server-side and these operations + * are never retried on a transport failure. + * + * Kept as an explicit set rather than a name pattern so that adding an + * operation cannot silently opt it into retries; `endpoints.test.ts` asserts + * this set equals the non-GET operations in the registry. + */ +export const NON_IDEMPOTENT_OPERATIONS: ReadonlySet = new Set([ + 'contactsCreateOrUpdate', + 'contactsUpdate', + 'contactsDelete', + 'listsCreate', + 'listsDelete', + 'listsUpdateSubscription', + 'tagsCreate', + 'tagsUpdate', + 'tagsDelete', + 'tagsAddToContact', + 'tagsRemoveFromContact', + 'fieldsCreate', + 'fieldsUpdate', + 'fieldsDelete', + 'fieldOptionsCreateBulk', + 'fieldValuesSetForContact', + 'fieldValuesUpdate', + 'fieldValuesDelete', + 'fieldRelsCreate', + 'fieldRelsDelete', + 'groupMembersCreate', + 'groupMembersUpdate', + 'groupMembersDelete', + 'importsCreateBulk', + 'listGroupsCreate', + 'dealsUpdate', + 'dealsDelete', + 'dealsUpdateOwnersBulk', + 'dealGroupsCreate', + 'dealGroupsUpdate', + 'dealGroupsDelete', + 'dealStagesCreate', + 'dealStagesUpdate', + 'dealStagesDelete', + 'dealStagesMoveDeals', + 'dealStagesDeleteWithDeals', + 'dealTasksCreate', + 'dealTasksUpdate', + 'dealTasksDelete', + 'dealTaskTypesCreate', + 'dealTaskTypesUpdate', + 'taskOutcomesCreate', + 'dealRolesCreate', + 'dealRolesDelete', + 'contactDealsCreate', + 'contactDealsUpdate', + 'contactDealsDelete', + 'dealCustomFieldMetaCreate', + 'dealCustomFieldMetaUpdate', + 'dealCustomFieldMetaDelete', + 'dealCustomFieldDataUpdate', + 'dealCustomFieldDataDelete', + 'accountsCreate', + 'accountsUpdate', + 'accountsDelete', + 'accountsUpsert', + 'accountsDeleteBulk', + 'accountContactsCreate', + 'accountContactsUpdate', + 'accountContactsDelete', + 'accountCustomFieldMetaCreate', + 'accountCustomFieldMetaUpdate', + 'accountCustomFieldMetaDelete', + 'accountCustomFieldDataCreate', + 'accountCustomFieldDataUpdate', + 'accountCustomFieldDataDelete', + 'accountCustomFieldDataCreateBulk', + 'accountCustomFieldDataUpdateBulk', + 'notesCreate', + 'notesUpdate', + 'notesDelete', + 'notesAddToContact', + 'campaignsCreate', + 'campaignsUpdate', + 'campaignsDuplicate', + 'messagesCreate', + 'messagesUpdate', + 'messagesDelete', + 'savedResponsesCreate', + 'savedResponsesUpdate', + 'savedResponsesDelete', + 'formsDelete', + 'formsCreateOptin', + 'personalizationsCreate', + 'personalizationsUpdate', + 'personalizationsDelete', + 'personalizationsDeleteBulk', + 'personalizationsLock', + 'personalizationsUnlock', + 'templatesCreateShareLink', + 'contactAutomationsAdd', + 'contactAutomationsRemove', + 'segmentsCreate', + 'segmentsUpdate', + 'segmentsDelete', + 'connectionsCreate', + 'connectionsUpdate', + 'connectionsDelete', + 'ecomCustomersCreate', + 'ecomCustomersUpdate', + 'ecomCustomersDelete', + 'ecomOrdersCreate', + 'ecomOrdersUpdate', + 'ecomOrdersDelete', + 'customObjectSchemasCreate', + 'customObjectSchemasUpdate', + 'customObjectSchemasDelete', + 'customObjectRecordsUpsert', + 'customObjectRecordsDelete', + 'customObjectRecordsDeleteByExternalId', + 'webhooksCreate', + 'webhooksUpdate', + 'webhooksDelete', + 'usersCreate', + 'usersUpdate', + 'usersDelete', + 'groupsCreate', + 'groupsUpdate', + 'groupsDelete', + 'addressesCreate', + 'addressesUpdate', + 'addressesDelete', + 'calendarsCreate', + 'calendarsUpdate', + 'calendarsDelete', + 'eventTrackingEventsCreate', + 'eventTrackingEventsDelete', + 'trackingSetSiteStatus', + 'trackingSetEventStatus', + 'trackingTrackEvent', + 'trackingAddWhitelist', + 'trackingRemoveWhitelist', + 'brandingsUpdate', + 'configsUpdate', + 'productsCreate', + 'productsUpdate', + 'productsDelete', + 'productsUpsertBulk', + 'ordersUpsertBulk', + 'ordersUpsertBulkAsync', + 'recurringPaymentsUpsertBulk', + 'browseSessionsSave', + 'browseSessionsAddToCart', + 'smsBroadcastsCreateSnapshot', + 'addressGroupsDelete', + 'ecomOrdersUpsert', + 'notesCreateForAccount', + 'notesCreateForDeal', + 'notesUpdateForAccount', + 'notesUpdateForDeal', + 'contactTasksCreate', + 'segmentsV2Create', + 'segmentsV2Update', + 'segmentsV2Delete', + 'segmentsV2RevertToTimestamp', + 'segmentsV2MatchAll', + 'taskRemindersCreate', + 'customObjectSchemasCreateChild', + 'browseSessionsTestEvent', +]); + +function isNonIdempotent(operation: string): boolean { + return NON_IDEMPOTENT_OPERATIONS.has(operation); +} + +export const errorHandlers = { + /** + * A missing or malformed credential is a configuration fault, not a + * transient one. Matched first so that it is never retried and never + * reported as a generic failure. + */ + CONFIGURATION_ERROR: { + match: (error) => { + // The client raises ActiveCampaignAPIError with a code; the shared + // account resolver raises the core's AuthMissingError. Both mean the + // integration is misconfigured rather than that the API failed, and + // neither becomes valid on a retry. + if (error instanceof AuthMissingError) { + return true; + } + const code = (error as { code?: string }).code; + return ( + code === 'MISSING_API_TOKEN' || + code === 'MISSING_ACCOUNT' || + code === 'INVALID_ACCOUNT' + ); + }, + handler: async (error, context) => { + console.error( + `[ACTIVECAMPAIGN:${context.operation}] Configuration error: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * ActiveCampaign allows 5 requests per second per account across both the + * REST and GraphQL surfaces and returns 429 with a `Retry-After` once the + * budget is exhausted. The request was rejected rather than applied, so + * replaying it is safe even for writes. + */ + RATE_LIMIT_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 429) { + return true; + } + const message = error.message.toLowerCase(); + return ( + message.includes('too many requests') || message.includes('rate limit') + ); + }, + handler: async (error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 401) { + return true; + } + return error.message.toLowerCase().includes('authentication'); + }, + handler: async (error, context) => { + console.warn( + `[ACTIVECAMPAIGN:${context.operation}] Authentication failed - check the API token and account name under Settings > Developer`, + ); + return { maxRetries: 0 }; + }, + }, + PERMISSION_ERROR: { + match: (error) => error instanceof ApiError && error.status === 403, + handler: async (error, context) => { + console.warn( + `[ACTIVECAMPAIGN:${context.operation}] Permission denied: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, + NOT_FOUND_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 404) { + return true; + } + // DNS failures say "no such host"; those must reach NETWORK_ERROR. + if (!(error instanceof ApiError)) { + return false; + } + const message = error.message.toLowerCase(); + return message.includes('not found'); + }, + handler: async (error, context) => { + console.warn( + `[ACTIVECAMPAIGN:${context.operation}] Resource not found: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * ActiveCampaign reports field-level rejections as 422 with an `errors` + * array, and malformed requests as 400. Neither becomes valid on a replay. + */ + VALIDATION_ERROR: { + match: (error) => + error instanceof ApiError && + (error.status === 400 || error.status === 422), + handler: async (error, context) => { + console.warn( + `[ACTIVECAMPAIGN:${context.operation}] Invalid request: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * A transport failure gives no evidence about whether the server applied + * the change, so only reads are replayed. See NON_IDEMPOTENT_OPERATIONS. + */ + NETWORK_ERROR: { + match: (error) => { + const message = error.message.toLowerCase(); + return ( + message.includes('network') || + message.includes('connection') || + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('etimedout') || + message.includes('fetch failed') || + message.includes('aborted') + ); + }, + handler: async (error, context) => { + if (isNonIdempotent(context.operation)) { + console.warn( + `[ACTIVECAMPAIGN:${context.operation}] Network error on a write operation - not retried, because ActiveCampaign offers no idempotency key and the write may already have been applied: ${error.message}`, + ); + return { maxRetries: 0 }; + } + console.warn( + `[ACTIVECAMPAIGN:${context.operation}] Network error: ${error.message}`, + ); + return { maxRetries: 3 }; + }, + }, + DEFAULT: { + match: () => true, + handler: async (error, context) => { + console.error( + `[ACTIVECAMPAIGN:${context.operation}] Unhandled error: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/activecampaign/index.ts b/packages/activecampaign/index.ts new file mode 100644 index 000000000..f6efd101e --- /dev/null +++ b/packages/activecampaign/index.ts @@ -0,0 +1,2998 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + Accounts, + Contacts, + Content, + Deals, + Fields, + Imports, + Lists, + Platform, + SegmentsV2, + Tags, +} from './endpoints'; +import type { + ActiveCampaignEndpointInputs, + ActiveCampaignEndpointOutputs, +} from './endpoints/types'; +import { + ActiveCampaignEndpointInputSchemas, + ActiveCampaignEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { ActiveCampaignSchema } from './schema'; + +export type ActiveCampaignPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + /** + * The account slug - the subdomain of the account's API URL, + * `https://.api-us1.com`. ActiveCampaign hosts every account on + * its own subdomain, so this is required alongside the API token and + * cannot be derived from it. + */ + account?: string; + hooks?: InternalActiveCampaignPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +/** + * Declaring `account: ['account']` generates `ctx.keys.get_account()`, which is + * how the second half of the credential reaches an endpoint when it is not + * passed as a plugin option. + */ +export const activecampaignAuthConfig = { + api_key: { + account: ['account'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type ActiveCampaignContext = CorsairPluginContext< + typeof ActiveCampaignSchema, + ActiveCampaignPluginOptions, + undefined, + typeof activecampaignAuthConfig +>; + +export type ActiveCampaignKeyBuilderContext = KeyBuilderContext< + ActiveCampaignPluginOptions, + typeof activecampaignAuthConfig +>; + +export type ActiveCampaignBoundEndpoints = BindEndpoints< + typeof activecampaignEndpointsNested +>; + +type ActiveCampaignEndpoint = + CorsairEndpoint< + ActiveCampaignContext, + ActiveCampaignEndpointInputs[K], + ActiveCampaignEndpointOutputs[K] + >; + +export type ActiveCampaignEndpoints = { + contactsList: ActiveCampaignEndpoint<'contactsList'>; + contactsGet: ActiveCampaignEndpoint<'contactsGet'>; + contactsFind: ActiveCampaignEndpoint<'contactsFind'>; + contactsCreateOrUpdate: ActiveCampaignEndpoint<'contactsCreateOrUpdate'>; + contactsUpdate: ActiveCampaignEndpoint<'contactsUpdate'>; + contactsDelete: ActiveCampaignEndpoint<'contactsDelete'>; + contactsGetLists: ActiveCampaignEndpoint<'contactsGetLists'>; + contactsGetTags: ActiveCampaignEndpoint<'contactsGetTags'>; + contactsGetFieldValues: ActiveCampaignEndpoint<'contactsGetFieldValues'>; + contactsGetAutomations: ActiveCampaignEndpoint<'contactsGetAutomations'>; + contactsGetGeoIps: ActiveCampaignEndpoint<'contactsGetGeoIps'>; + contactsGetScoreValues: ActiveCampaignEndpoint<'contactsGetScoreValues'>; + contactsGetDeals: ActiveCampaignEndpoint<'contactsGetDeals'>; + listsList: ActiveCampaignEndpoint<'listsList'>; + listsGet: ActiveCampaignEndpoint<'listsGet'>; + listsCreate: ActiveCampaignEndpoint<'listsCreate'>; + listsDelete: ActiveCampaignEndpoint<'listsDelete'>; + listsUpdateSubscription: ActiveCampaignEndpoint<'listsUpdateSubscription'>; + contactListsList: ActiveCampaignEndpoint<'contactListsList'>; + tagsList: ActiveCampaignEndpoint<'tagsList'>; + tagsGet: ActiveCampaignEndpoint<'tagsGet'>; + tagsCreate: ActiveCampaignEndpoint<'tagsCreate'>; + tagsUpdate: ActiveCampaignEndpoint<'tagsUpdate'>; + tagsDelete: ActiveCampaignEndpoint<'tagsDelete'>; + tagsAddToContact: ActiveCampaignEndpoint<'tagsAddToContact'>; + tagsRemoveFromContact: ActiveCampaignEndpoint<'tagsRemoveFromContact'>; + contactTagsList: ActiveCampaignEndpoint<'contactTagsList'>; + fieldsList: ActiveCampaignEndpoint<'fieldsList'>; + fieldsGet: ActiveCampaignEndpoint<'fieldsGet'>; + fieldsCreate: ActiveCampaignEndpoint<'fieldsCreate'>; + fieldsUpdate: ActiveCampaignEndpoint<'fieldsUpdate'>; + fieldsDelete: ActiveCampaignEndpoint<'fieldsDelete'>; + fieldOptionsCreateBulk: ActiveCampaignEndpoint<'fieldOptionsCreateBulk'>; + fieldValuesList: ActiveCampaignEndpoint<'fieldValuesList'>; + fieldValuesGet: ActiveCampaignEndpoint<'fieldValuesGet'>; + fieldValuesSetForContact: ActiveCampaignEndpoint<'fieldValuesSetForContact'>; + fieldValuesUpdate: ActiveCampaignEndpoint<'fieldValuesUpdate'>; + fieldValuesDelete: ActiveCampaignEndpoint<'fieldValuesDelete'>; + fieldRelsList: ActiveCampaignEndpoint<'fieldRelsList'>; + fieldRelsCreate: ActiveCampaignEndpoint<'fieldRelsCreate'>; + fieldRelsDelete: ActiveCampaignEndpoint<'fieldRelsDelete'>; + groupMembersList: ActiveCampaignEndpoint<'groupMembersList'>; + groupMembersCreate: ActiveCampaignEndpoint<'groupMembersCreate'>; + groupMembersUpdate: ActiveCampaignEndpoint<'groupMembersUpdate'>; + groupMembersDelete: ActiveCampaignEndpoint<'groupMembersDelete'>; + contactsGetLogs: ActiveCampaignEndpoint<'contactsGetLogs'>; + contactsGetTrackingLogs: ActiveCampaignEndpoint<'contactsGetTrackingLogs'>; + contactsGetGoals: ActiveCampaignEndpoint<'contactsGetGoals'>; + contactsGetAccountContacts: ActiveCampaignEndpoint<'contactsGetAccountContacts'>; + contactsGetNotes: ActiveCampaignEndpoint<'contactsGetNotes'>; + contactsGetData: ActiveCampaignEndpoint<'contactsGetData'>; + contactsGetOrganization: ActiveCampaignEndpoint<'contactsGetOrganization'>; + contactsGetPlusAppend: ActiveCampaignEndpoint<'contactsGetPlusAppend'>; + activitiesList: ActiveCampaignEndpoint<'activitiesList'>; + importsCreateBulk: ActiveCampaignEndpoint<'importsCreateBulk'>; + importsList: ActiveCampaignEndpoint<'importsList'>; + importsGetStatus: ActiveCampaignEndpoint<'importsGetStatus'>; + listGroupsCreate: ActiveCampaignEndpoint<'listGroupsCreate'>; + dealsList: ActiveCampaignEndpoint<'dealsList'>; + dealsListFiltered: ActiveCampaignEndpoint<'dealsListFiltered'>; + dealsGet: ActiveCampaignEndpoint<'dealsGet'>; + dealsUpdate: ActiveCampaignEndpoint<'dealsUpdate'>; + dealsDelete: ActiveCampaignEndpoint<'dealsDelete'>; + dealsUpdateOwnersBulk: ActiveCampaignEndpoint<'dealsUpdateOwnersBulk'>; + dealGroupsList: ActiveCampaignEndpoint<'dealGroupsList'>; + dealGroupsGet: ActiveCampaignEndpoint<'dealGroupsGet'>; + dealGroupsCreate: ActiveCampaignEndpoint<'dealGroupsCreate'>; + dealGroupsUpdate: ActiveCampaignEndpoint<'dealGroupsUpdate'>; + dealGroupsDelete: ActiveCampaignEndpoint<'dealGroupsDelete'>; + dealStagesList: ActiveCampaignEndpoint<'dealStagesList'>; + dealStagesGet: ActiveCampaignEndpoint<'dealStagesGet'>; + dealStagesCreate: ActiveCampaignEndpoint<'dealStagesCreate'>; + dealStagesUpdate: ActiveCampaignEndpoint<'dealStagesUpdate'>; + dealStagesDelete: ActiveCampaignEndpoint<'dealStagesDelete'>; + dealStagesMoveDeals: ActiveCampaignEndpoint<'dealStagesMoveDeals'>; + dealStagesDeleteWithDeals: ActiveCampaignEndpoint<'dealStagesDeleteWithDeals'>; + dealTasksList: ActiveCampaignEndpoint<'dealTasksList'>; + dealTasksGet: ActiveCampaignEndpoint<'dealTasksGet'>; + dealTasksCreate: ActiveCampaignEndpoint<'dealTasksCreate'>; + dealTasksUpdate: ActiveCampaignEndpoint<'dealTasksUpdate'>; + dealTasksDelete: ActiveCampaignEndpoint<'dealTasksDelete'>; + dealTaskTypesList: ActiveCampaignEndpoint<'dealTaskTypesList'>; + dealTaskTypesGet: ActiveCampaignEndpoint<'dealTaskTypesGet'>; + dealTaskTypesCreate: ActiveCampaignEndpoint<'dealTaskTypesCreate'>; + dealTaskTypesUpdate: ActiveCampaignEndpoint<'dealTaskTypesUpdate'>; + taskOutcomesList: ActiveCampaignEndpoint<'taskOutcomesList'>; + taskOutcomesGet: ActiveCampaignEndpoint<'taskOutcomesGet'>; + taskOutcomesCreate: ActiveCampaignEndpoint<'taskOutcomesCreate'>; + dealRolesList: ActiveCampaignEndpoint<'dealRolesList'>; + dealRolesCreate: ActiveCampaignEndpoint<'dealRolesCreate'>; + dealRolesDelete: ActiveCampaignEndpoint<'dealRolesDelete'>; + contactDealsList: ActiveCampaignEndpoint<'contactDealsList'>; + contactDealsGet: ActiveCampaignEndpoint<'contactDealsGet'>; + contactDealsCreate: ActiveCampaignEndpoint<'contactDealsCreate'>; + contactDealsUpdate: ActiveCampaignEndpoint<'contactDealsUpdate'>; + contactDealsDelete: ActiveCampaignEndpoint<'contactDealsDelete'>; + dealCustomFieldMetaList: ActiveCampaignEndpoint<'dealCustomFieldMetaList'>; + dealCustomFieldMetaGet: ActiveCampaignEndpoint<'dealCustomFieldMetaGet'>; + dealCustomFieldMetaCreate: ActiveCampaignEndpoint<'dealCustomFieldMetaCreate'>; + dealCustomFieldMetaUpdate: ActiveCampaignEndpoint<'dealCustomFieldMetaUpdate'>; + dealCustomFieldMetaDelete: ActiveCampaignEndpoint<'dealCustomFieldMetaDelete'>; + dealCustomFieldDataList: ActiveCampaignEndpoint<'dealCustomFieldDataList'>; + dealCustomFieldDataGet: ActiveCampaignEndpoint<'dealCustomFieldDataGet'>; + dealCustomFieldDataUpdate: ActiveCampaignEndpoint<'dealCustomFieldDataUpdate'>; + dealCustomFieldDataDelete: ActiveCampaignEndpoint<'dealCustomFieldDataDelete'>; + dealActivitiesList: ActiveCampaignEndpoint<'dealActivitiesList'>; + accountsList: ActiveCampaignEndpoint<'accountsList'>; + accountsGet: ActiveCampaignEndpoint<'accountsGet'>; + accountsCreate: ActiveCampaignEndpoint<'accountsCreate'>; + accountsUpdate: ActiveCampaignEndpoint<'accountsUpdate'>; + accountsDelete: ActiveCampaignEndpoint<'accountsDelete'>; + accountsUpsert: ActiveCampaignEndpoint<'accountsUpsert'>; + accountsDeleteBulk: ActiveCampaignEndpoint<'accountsDeleteBulk'>; + accountContactsList: ActiveCampaignEndpoint<'accountContactsList'>; + accountContactsGet: ActiveCampaignEndpoint<'accountContactsGet'>; + accountContactsCreate: ActiveCampaignEndpoint<'accountContactsCreate'>; + accountContactsUpdate: ActiveCampaignEndpoint<'accountContactsUpdate'>; + accountContactsDelete: ActiveCampaignEndpoint<'accountContactsDelete'>; + accountCustomFieldMetaList: ActiveCampaignEndpoint<'accountCustomFieldMetaList'>; + accountCustomFieldMetaGet: ActiveCampaignEndpoint<'accountCustomFieldMetaGet'>; + accountCustomFieldMetaCreate: ActiveCampaignEndpoint<'accountCustomFieldMetaCreate'>; + accountCustomFieldMetaUpdate: ActiveCampaignEndpoint<'accountCustomFieldMetaUpdate'>; + accountCustomFieldMetaDelete: ActiveCampaignEndpoint<'accountCustomFieldMetaDelete'>; + accountCustomFieldDataList: ActiveCampaignEndpoint<'accountCustomFieldDataList'>; + accountCustomFieldDataGet: ActiveCampaignEndpoint<'accountCustomFieldDataGet'>; + accountCustomFieldDataCreate: ActiveCampaignEndpoint<'accountCustomFieldDataCreate'>; + accountCustomFieldDataUpdate: ActiveCampaignEndpoint<'accountCustomFieldDataUpdate'>; + accountCustomFieldDataDelete: ActiveCampaignEndpoint<'accountCustomFieldDataDelete'>; + accountCustomFieldDataCreateBulk: ActiveCampaignEndpoint<'accountCustomFieldDataCreateBulk'>; + accountCustomFieldDataUpdateBulk: ActiveCampaignEndpoint<'accountCustomFieldDataUpdateBulk'>; + notesList: ActiveCampaignEndpoint<'notesList'>; + notesGet: ActiveCampaignEndpoint<'notesGet'>; + notesCreate: ActiveCampaignEndpoint<'notesCreate'>; + notesUpdate: ActiveCampaignEndpoint<'notesUpdate'>; + notesDelete: ActiveCampaignEndpoint<'notesDelete'>; + notesAddToContact: ActiveCampaignEndpoint<'notesAddToContact'>; + campaignsList: ActiveCampaignEndpoint<'campaignsList'>; + campaignsGet: ActiveCampaignEndpoint<'campaignsGet'>; + campaignsCreate: ActiveCampaignEndpoint<'campaignsCreate'>; + campaignsUpdate: ActiveCampaignEndpoint<'campaignsUpdate'>; + campaignsDuplicate: ActiveCampaignEndpoint<'campaignsDuplicate'>; + campaignsGetLinks: ActiveCampaignEndpoint<'campaignsGetLinks'>; + campaignsGetMessages: ActiveCampaignEndpoint<'campaignsGetMessages'>; + campaignsGetAutomations: ActiveCampaignEndpoint<'campaignsGetAutomations'>; + campaignsGetAutomationLists: ActiveCampaignEndpoint<'campaignsGetAutomationLists'>; + campaignsGetUser: ActiveCampaignEndpoint<'campaignsGetUser'>; + messagesList: ActiveCampaignEndpoint<'messagesList'>; + messagesGet: ActiveCampaignEndpoint<'messagesGet'>; + messagesCreate: ActiveCampaignEndpoint<'messagesCreate'>; + messagesUpdate: ActiveCampaignEndpoint<'messagesUpdate'>; + messagesDelete: ActiveCampaignEndpoint<'messagesDelete'>; + savedResponsesList: ActiveCampaignEndpoint<'savedResponsesList'>; + savedResponsesGet: ActiveCampaignEndpoint<'savedResponsesGet'>; + savedResponsesCreate: ActiveCampaignEndpoint<'savedResponsesCreate'>; + savedResponsesUpdate: ActiveCampaignEndpoint<'savedResponsesUpdate'>; + savedResponsesDelete: ActiveCampaignEndpoint<'savedResponsesDelete'>; + formsList: ActiveCampaignEndpoint<'formsList'>; + formsGet: ActiveCampaignEndpoint<'formsGet'>; + formsDelete: ActiveCampaignEndpoint<'formsDelete'>; + formsCreateOptin: ActiveCampaignEndpoint<'formsCreateOptin'>; + personalizationsList: ActiveCampaignEndpoint<'personalizationsList'>; + personalizationsGet: ActiveCampaignEndpoint<'personalizationsGet'>; + personalizationsCreate: ActiveCampaignEndpoint<'personalizationsCreate'>; + personalizationsUpdate: ActiveCampaignEndpoint<'personalizationsUpdate'>; + personalizationsDelete: ActiveCampaignEndpoint<'personalizationsDelete'>; + personalizationsDeleteBulk: ActiveCampaignEndpoint<'personalizationsDeleteBulk'>; + personalizationsLock: ActiveCampaignEndpoint<'personalizationsLock'>; + personalizationsUnlock: ActiveCampaignEndpoint<'personalizationsUnlock'>; + templatesGet: ActiveCampaignEndpoint<'templatesGet'>; + templatesCreateShareLink: ActiveCampaignEndpoint<'templatesCreateShareLink'>; + automationsList: ActiveCampaignEndpoint<'automationsList'>; + contactAutomationsList: ActiveCampaignEndpoint<'contactAutomationsList'>; + contactAutomationsGet: ActiveCampaignEndpoint<'contactAutomationsGet'>; + contactAutomationsEntryCounts: ActiveCampaignEndpoint<'contactAutomationsEntryCounts'>; + contactAutomationsAdd: ActiveCampaignEndpoint<'contactAutomationsAdd'>; + contactAutomationsRemove: ActiveCampaignEndpoint<'contactAutomationsRemove'>; + segmentsList: ActiveCampaignEndpoint<'segmentsList'>; + segmentsGet: ActiveCampaignEndpoint<'segmentsGet'>; + segmentsCreate: ActiveCampaignEndpoint<'segmentsCreate'>; + segmentsUpdate: ActiveCampaignEndpoint<'segmentsUpdate'>; + segmentsDelete: ActiveCampaignEndpoint<'segmentsDelete'>; + segmentsListAudiences: ActiveCampaignEndpoint<'segmentsListAudiences'>; + connectionsList: ActiveCampaignEndpoint<'connectionsList'>; + connectionsGet: ActiveCampaignEndpoint<'connectionsGet'>; + connectionsCreate: ActiveCampaignEndpoint<'connectionsCreate'>; + connectionsUpdate: ActiveCampaignEndpoint<'connectionsUpdate'>; + connectionsDelete: ActiveCampaignEndpoint<'connectionsDelete'>; + ecomCustomersList: ActiveCampaignEndpoint<'ecomCustomersList'>; + ecomCustomersGet: ActiveCampaignEndpoint<'ecomCustomersGet'>; + ecomCustomersCreate: ActiveCampaignEndpoint<'ecomCustomersCreate'>; + ecomCustomersUpdate: ActiveCampaignEndpoint<'ecomCustomersUpdate'>; + ecomCustomersDelete: ActiveCampaignEndpoint<'ecomCustomersDelete'>; + ecomOrdersList: ActiveCampaignEndpoint<'ecomOrdersList'>; + ecomOrdersGet: ActiveCampaignEndpoint<'ecomOrdersGet'>; + ecomOrdersCreate: ActiveCampaignEndpoint<'ecomOrdersCreate'>; + ecomOrdersUpdate: ActiveCampaignEndpoint<'ecomOrdersUpdate'>; + ecomOrdersDelete: ActiveCampaignEndpoint<'ecomOrdersDelete'>; + ecomOrderProductsList: ActiveCampaignEndpoint<'ecomOrderProductsList'>; + ecomOrderProductsGet: ActiveCampaignEndpoint<'ecomOrderProductsGet'>; + customObjectSchemasList: ActiveCampaignEndpoint<'customObjectSchemasList'>; + customObjectSchemasGet: ActiveCampaignEndpoint<'customObjectSchemasGet'>; + customObjectSchemasCreate: ActiveCampaignEndpoint<'customObjectSchemasCreate'>; + customObjectSchemasUpdate: ActiveCampaignEndpoint<'customObjectSchemasUpdate'>; + customObjectSchemasDelete: ActiveCampaignEndpoint<'customObjectSchemasDelete'>; + customObjectRecordsList: ActiveCampaignEndpoint<'customObjectRecordsList'>; + customObjectRecordsUpsert: ActiveCampaignEndpoint<'customObjectRecordsUpsert'>; + customObjectRecordsGet: ActiveCampaignEndpoint<'customObjectRecordsGet'>; + customObjectRecordsGetByExternalId: ActiveCampaignEndpoint<'customObjectRecordsGetByExternalId'>; + customObjectRecordsDelete: ActiveCampaignEndpoint<'customObjectRecordsDelete'>; + customObjectRecordsDeleteByExternalId: ActiveCampaignEndpoint<'customObjectRecordsDeleteByExternalId'>; + webhooksList: ActiveCampaignEndpoint<'webhooksList'>; + webhooksGet: ActiveCampaignEndpoint<'webhooksGet'>; + webhooksCreate: ActiveCampaignEndpoint<'webhooksCreate'>; + webhooksUpdate: ActiveCampaignEndpoint<'webhooksUpdate'>; + webhooksDelete: ActiveCampaignEndpoint<'webhooksDelete'>; + usersList: ActiveCampaignEndpoint<'usersList'>; + usersGet: ActiveCampaignEndpoint<'usersGet'>; + usersCreate: ActiveCampaignEndpoint<'usersCreate'>; + usersUpdate: ActiveCampaignEndpoint<'usersUpdate'>; + usersDelete: ActiveCampaignEndpoint<'usersDelete'>; + usersGetMe: ActiveCampaignEndpoint<'usersGetMe'>; + usersGetByUsername: ActiveCampaignEndpoint<'usersGetByUsername'>; + groupsList: ActiveCampaignEndpoint<'groupsList'>; + groupsGet: ActiveCampaignEndpoint<'groupsGet'>; + groupsCreate: ActiveCampaignEndpoint<'groupsCreate'>; + groupsUpdate: ActiveCampaignEndpoint<'groupsUpdate'>; + groupsDelete: ActiveCampaignEndpoint<'groupsDelete'>; + groupLimitsList: ActiveCampaignEndpoint<'groupLimitsList'>; + addressesList: ActiveCampaignEndpoint<'addressesList'>; + addressesGet: ActiveCampaignEndpoint<'addressesGet'>; + addressesCreate: ActiveCampaignEndpoint<'addressesCreate'>; + addressesUpdate: ActiveCampaignEndpoint<'addressesUpdate'>; + addressesDelete: ActiveCampaignEndpoint<'addressesDelete'>; + calendarsList: ActiveCampaignEndpoint<'calendarsList'>; + calendarsGet: ActiveCampaignEndpoint<'calendarsGet'>; + calendarsCreate: ActiveCampaignEndpoint<'calendarsCreate'>; + calendarsUpdate: ActiveCampaignEndpoint<'calendarsUpdate'>; + calendarsDelete: ActiveCampaignEndpoint<'calendarsDelete'>; + eventTrackingEventsList: ActiveCampaignEndpoint<'eventTrackingEventsList'>; + eventTrackingEventsCreate: ActiveCampaignEndpoint<'eventTrackingEventsCreate'>; + eventTrackingEventsDelete: ActiveCampaignEndpoint<'eventTrackingEventsDelete'>; + trackingGetSiteStatus: ActiveCampaignEndpoint<'trackingGetSiteStatus'>; + trackingGetEventStatus: ActiveCampaignEndpoint<'trackingGetEventStatus'>; + trackingSetSiteStatus: ActiveCampaignEndpoint<'trackingSetSiteStatus'>; + trackingSetEventStatus: ActiveCampaignEndpoint<'trackingSetEventStatus'>; + trackingTrackEvent: ActiveCampaignEndpoint<'trackingTrackEvent'>; + trackingListWhitelist: ActiveCampaignEndpoint<'trackingListWhitelist'>; + trackingAddWhitelist: ActiveCampaignEndpoint<'trackingAddWhitelist'>; + trackingRemoveWhitelist: ActiveCampaignEndpoint<'trackingRemoveWhitelist'>; + scoresList: ActiveCampaignEndpoint<'scoresList'>; + emailActivitiesList: ActiveCampaignEndpoint<'emailActivitiesList'>; + brandingsGet: ActiveCampaignEndpoint<'brandingsGet'>; + brandingsUpdate: ActiveCampaignEndpoint<'brandingsUpdate'>; + configsUpdate: ActiveCampaignEndpoint<'configsUpdate'>; + productsSearch: ActiveCampaignEndpoint<'productsSearch'>; + productsGet: ActiveCampaignEndpoint<'productsGet'>; + productsCreate: ActiveCampaignEndpoint<'productsCreate'>; + productsUpdate: ActiveCampaignEndpoint<'productsUpdate'>; + productsDelete: ActiveCampaignEndpoint<'productsDelete'>; + productsUpsertBulk: ActiveCampaignEndpoint<'productsUpsertBulk'>; + ordersUpsertBulk: ActiveCampaignEndpoint<'ordersUpsertBulk'>; + ordersUpsertBulkAsync: ActiveCampaignEndpoint<'ordersUpsertBulkAsync'>; + recurringPaymentsSearch: ActiveCampaignEndpoint<'recurringPaymentsSearch'>; + recurringPaymentsUpsertBulk: ActiveCampaignEndpoint<'recurringPaymentsUpsertBulk'>; + browseSessionsSearch: ActiveCampaignEndpoint<'browseSessionsSearch'>; + browseSessionsSave: ActiveCampaignEndpoint<'browseSessionsSave'>; + browseSessionsAddToCart: ActiveCampaignEndpoint<'browseSessionsAddToCart'>; + smsBroadcastsList: ActiveCampaignEndpoint<'smsBroadcastsList'>; + smsBroadcastsGetMetrics: ActiveCampaignEndpoint<'smsBroadcastsGetMetrics'>; + smsBroadcastsGetSnapshot: ActiveCampaignEndpoint<'smsBroadcastsGetSnapshot'>; + smsBroadcastsCreateSnapshot: ActiveCampaignEndpoint<'smsBroadcastsCreateSnapshot'>; + smsBroadcastsGetFailures: ActiveCampaignEndpoint<'smsBroadcastsGetFailures'>; + smsBroadcastsGetRecipients: ActiveCampaignEndpoint<'smsBroadcastsGetRecipients'>; + smsCreditsGet: ActiveCampaignEndpoint<'smsCreditsGet'>; + trackingGetCode: ActiveCampaignEndpoint<'trackingGetCode'>; + smsBroadcastListsList: ActiveCampaignEndpoint<'smsBroadcastListsList'>; + addressGroupsDelete: ActiveCampaignEndpoint<'addressGroupsDelete'>; + ecomOrdersFind: ActiveCampaignEndpoint<'ecomOrdersFind'>; + ecomOrdersUpsert: ActiveCampaignEndpoint<'ecomOrdersUpsert'>; + ecomOrderProductsListForOrder: ActiveCampaignEndpoint<'ecomOrderProductsListForOrder'>; + notesCreateForAccount: ActiveCampaignEndpoint<'notesCreateForAccount'>; + notesCreateForDeal: ActiveCampaignEndpoint<'notesCreateForDeal'>; + notesUpdateForAccount: ActiveCampaignEndpoint<'notesUpdateForAccount'>; + notesUpdateForDeal: ActiveCampaignEndpoint<'notesUpdateForDeal'>; + contactTasksCreate: ActiveCampaignEndpoint<'contactTasksCreate'>; + contactTasksFind: ActiveCampaignEndpoint<'contactTasksFind'>; + segmentsV2Create: ActiveCampaignEndpoint<'segmentsV2Create'>; + segmentsV2Get: ActiveCampaignEndpoint<'segmentsV2Get'>; + segmentsV2Update: ActiveCampaignEndpoint<'segmentsV2Update'>; + segmentsV2Delete: ActiveCampaignEndpoint<'segmentsV2Delete'>; + segmentsV2GetAtTimestamp: ActiveCampaignEndpoint<'segmentsV2GetAtTimestamp'>; + segmentsV2RevertToTimestamp: ActiveCampaignEndpoint<'segmentsV2RevertToTimestamp'>; + segmentsV2RecentCounts: ActiveCampaignEndpoint<'segmentsV2RecentCounts'>; + segmentsV2CountHistory: ActiveCampaignEndpoint<'segmentsV2CountHistory'>; + segmentsV2CountAtTimestamp: ActiveCampaignEndpoint<'segmentsV2CountAtTimestamp'>; + segmentsV2Match: ActiveCampaignEndpoint<'segmentsV2Match'>; + segmentsV2MatchByExternalId: ActiveCampaignEndpoint<'segmentsV2MatchByExternalId'>; + segmentsV2MatchAll: ActiveCampaignEndpoint<'segmentsV2MatchAll'>; + segmentsV2MatchAllResult: ActiveCampaignEndpoint<'segmentsV2MatchAllResult'>; + segmentsV2MatchSomeResult: ActiveCampaignEndpoint<'segmentsV2MatchSomeResult'>; + taskRemindersCreate: ActiveCampaignEndpoint<'taskRemindersCreate'>; + customObjectSchemasCreateChild: ActiveCampaignEndpoint<'customObjectSchemasCreateChild'>; + importsListAggregate: ActiveCampaignEndpoint<'importsListAggregate'>; + browseSessionsTestEvent: ActiveCampaignEndpoint<'browseSessionsTestEvent'>; +}; + +/** + * The nested tree is grouped by API resource, and each leaf is named so that + * `.` camel-cased gives exactly the operation key used by the + * schema registry - `fieldValues.setForContact` -> `fieldValuesSetForContact`. + * `endpoints.test.ts` asserts that mapping holds for every path, because the + * retry-safety check depends on translating one into the other. + */ +const activecampaignEndpointsNested = { + contacts: { + list: Contacts.list, + get: Contacts.get, + find: Contacts.find, + createOrUpdate: Contacts.createOrUpdate, + update: Contacts.update, + delete: Contacts.remove, + getLists: Contacts.getLists, + getTags: Contacts.getTags, + getFieldValues: Contacts.getFieldValues, + getAutomations: Contacts.getAutomations, + getGeoIps: Contacts.getGeoIps, + getScoreValues: Contacts.getScoreValues, + getDeals: Contacts.getDeals, + getLogs: Contacts.getLogs, + getTrackingLogs: Contacts.getTrackingLogs, + getGoals: Contacts.getGoals, + getAccountContacts: Contacts.getAccountContacts, + getNotes: Contacts.getNotes, + getData: Contacts.getData, + getOrganization: Contacts.getOrganization, + getPlusAppend: Contacts.getPlusAppend, + }, + lists: { + list: Lists.list, + get: Lists.get, + create: Lists.create, + delete: Lists.remove, + updateSubscription: Lists.updateSubscription, + }, + contactLists: { + list: Lists.listContactLists, + }, + tags: { + list: Tags.list, + get: Tags.get, + create: Tags.create, + update: Tags.update, + delete: Tags.remove, + addToContact: Tags.addToContact, + removeFromContact: Tags.removeFromContact, + }, + contactTags: { + list: Tags.listContactTags, + }, + fields: { + list: Fields.list, + get: Fields.get, + create: Fields.create, + update: Fields.update, + delete: Fields.remove, + }, + fieldOptions: { + createBulk: Fields.createOptionsBulk, + }, + fieldValues: { + list: Fields.listValues, + get: Fields.getValue, + setForContact: Fields.setValueForContact, + update: Fields.updateValue, + delete: Fields.removeValue, + }, + fieldRels: { + list: Fields.listRels, + create: Fields.createRel, + delete: Fields.removeRel, + }, + groupMembers: { + list: Fields.listGroupMembers, + create: Fields.createGroupMember, + update: Fields.updateGroupMember, + delete: Fields.removeGroupMember, + }, + activities: { + list: Contacts.listActivities, + }, + imports: { + listAggregate: Platform.listImportAggregate, + createBulk: Imports.createBulk, + list: Imports.list, + getStatus: Imports.getStatus, + }, + listGroups: { + create: Lists.createListGroup, + }, + deals: { + list: Deals.list, + listFiltered: Deals.listFiltered, + get: Deals.get, + update: Deals.update, + delete: Deals.remove, + updateOwnersBulk: Deals.updateOwnersBulk, + }, + dealGroups: { + list: Deals.listGroups, + get: Deals.getGroup, + create: Deals.createGroup, + update: Deals.updateGroup, + delete: Deals.removeGroup, + }, + dealStages: { + list: Deals.listStages, + get: Deals.getStage, + create: Deals.createStage, + update: Deals.updateStage, + delete: Deals.removeStage, + moveDeals: Deals.moveStageDeals, + deleteWithDeals: Deals.removeStageWithDeals, + }, + dealTasks: { + list: Deals.listTasks, + get: Deals.getTask, + create: Deals.createTask, + update: Deals.updateTask, + delete: Deals.removeTask, + }, + dealTaskTypes: { + list: Deals.listTaskTypes, + get: Deals.getTaskType, + create: Deals.createTaskType, + update: Deals.updateTaskType, + }, + taskOutcomes: { + list: Deals.listOutcomes, + get: Deals.getOutcome, + create: Deals.createOutcome, + }, + dealRoles: { + list: Deals.listRoles, + create: Deals.createRole, + delete: Deals.removeRole, + }, + contactDeals: { + list: Deals.listSecondaryContacts, + get: Deals.getSecondaryContact, + create: Deals.addSecondaryContact, + update: Deals.updateSecondaryContact, + delete: Deals.removeSecondaryContact, + }, + dealCustomFieldMeta: { + list: Deals.listFieldMeta, + get: Deals.getFieldMeta, + create: Deals.createFieldMeta, + update: Deals.updateFieldMeta, + delete: Deals.removeFieldMeta, + }, + dealCustomFieldData: { + list: Deals.listFieldData, + get: Deals.getFieldData, + update: Deals.updateFieldData, + delete: Deals.removeFieldData, + }, + dealActivities: { + list: Deals.listActivities, + }, + accounts: { + list: Accounts.list, + get: Accounts.get, + create: Accounts.create, + update: Accounts.update, + delete: Accounts.remove, + upsert: Accounts.upsert, + deleteBulk: Accounts.removeBulk, + }, + accountContacts: { + list: Accounts.listContacts, + get: Accounts.getContact, + create: Accounts.createContact, + update: Accounts.updateContact, + delete: Accounts.removeContact, + }, + accountCustomFieldMeta: { + list: Accounts.listFieldMeta, + get: Accounts.getFieldMeta, + create: Accounts.createFieldMeta, + update: Accounts.updateFieldMeta, + delete: Accounts.removeFieldMeta, + }, + accountCustomFieldData: { + list: Accounts.listFieldData, + get: Accounts.getFieldData, + create: Accounts.createFieldData, + update: Accounts.updateFieldData, + delete: Accounts.removeFieldData, + createBulk: Accounts.createFieldDataBulk, + updateBulk: Accounts.updateFieldDataBulk, + }, + notes: { + createForAccount: Accounts.createAccountNote, + createForDeal: Accounts.createDealNote, + updateForAccount: Accounts.updateAccountNote, + updateForDeal: Accounts.updateDealNote, + list: Accounts.listNotes, + get: Accounts.getNote, + create: Accounts.createNote, + update: Accounts.updateNote, + delete: Accounts.removeNote, + addToContact: Accounts.addContactNote, + }, + campaigns: { + list: Content.listCampaigns, + get: Content.getCampaign, + create: Content.createCampaign, + update: Content.updateCampaign, + duplicate: Content.duplicateCampaign, + getLinks: Content.getCampaignLinks, + getMessages: Content.getCampaignMessages, + getAutomations: Content.getCampaignAutomations, + getAutomationLists: Content.getCampaignAutomationLists, + getUser: Content.getCampaignUser, + }, + messages: { + list: Content.listMessages, + get: Content.getMessage, + create: Content.createMessage, + update: Content.updateMessage, + delete: Content.removeMessage, + }, + savedResponses: { + list: Content.listSavedResponses, + get: Content.getSavedResponse, + create: Content.createSavedResponse, + update: Content.updateSavedResponse, + delete: Content.removeSavedResponse, + }, + forms: { + list: Content.listForms, + get: Content.getForm, + delete: Content.removeForm, + createOptin: Content.createFormOptin, + }, + personalizations: { + list: Content.listVariables, + get: Content.getVariable, + create: Content.createVariable, + update: Content.updateVariable, + delete: Content.removeVariable, + deleteBulk: Content.removeVariablesBulk, + lock: Content.lockVariable, + unlock: Content.unlockVariable, + }, + templates: { + get: Content.getTemplate, + createShareLink: Content.createTemplateShareLink, + }, + automations: { + list: Content.listAutomations, + }, + contactAutomations: { + list: Content.listContactAutomations, + get: Content.getContactAutomation, + entryCounts: Content.getAutomationEntryCounts, + add: Content.addContactToAutomation, + remove: Content.removeContactFromAutomation, + }, + segments: { + list: Content.listSegments, + get: Content.getSegment, + create: Content.createSegment, + update: Content.updateSegment, + delete: Content.removeSegment, + listAudiences: Content.listAudiences, + }, + connections: { + list: Platform.listConnections, + get: Platform.getConnection, + create: Platform.createConnection, + update: Platform.updateConnection, + delete: Platform.removeConnection, + }, + ecomCustomers: { + list: Platform.listCustomers, + get: Platform.getCustomer, + create: Platform.createCustomer, + update: Platform.updateCustomer, + delete: Platform.removeCustomer, + }, + ecomOrders: { + find: Platform.findOrder, + upsert: Platform.upsertOrder, + list: Platform.listOrders, + get: Platform.getOrder, + create: Platform.createOrder, + update: Platform.updateOrder, + delete: Platform.removeOrder, + }, + ecomOrderProducts: { + listForOrder: Platform.listProductsForOrder, + list: Platform.listOrderProducts, + get: Platform.getOrderProduct, + }, + customObjectSchemas: { + createChild: Platform.createChildSchema, + list: Platform.listSchemas, + get: Platform.getSchema, + create: Platform.createSchema, + update: Platform.updateSchema, + delete: Platform.removeSchema, + }, + customObjectRecords: { + list: Platform.listRecords, + upsert: Platform.upsertRecord, + get: Platform.getRecord, + getByExternalId: Platform.getRecordByExternalId, + delete: Platform.removeRecord, + deleteByExternalId: Platform.removeRecordByExternalId, + }, + webhooks: { + list: Platform.listWebhooks, + get: Platform.getWebhook, + create: Platform.createWebhook, + update: Platform.updateWebhook, + delete: Platform.removeWebhook, + }, + users: { + list: Platform.listUsers, + get: Platform.getUser, + create: Platform.createUser, + update: Platform.updateUser, + delete: Platform.removeUser, + getMe: Platform.getLoggedInUser, + getByUsername: Platform.getUserByUsername, + }, + groups: { + list: Platform.listGroups, + get: Platform.getGroup, + create: Platform.createGroup, + update: Platform.updateGroup, + delete: Platform.removeGroup, + }, + groupLimits: { + list: Platform.listGroupLimits, + }, + addresses: { + list: Platform.listAddresses, + get: Platform.getAddress, + create: Platform.createAddress, + update: Platform.updateAddress, + delete: Platform.removeAddress, + }, + calendars: { + list: Platform.listCalendars, + get: Platform.getCalendar, + create: Platform.createCalendar, + update: Platform.updateCalendar, + delete: Platform.removeCalendar, + }, + eventTrackingEvents: { + list: Platform.listEvents, + create: Platform.createEvent, + delete: Platform.removeEvent, + }, + tracking: { + getCode: Platform.getSiteTrackingCode, + getSiteStatus: Platform.getSiteTrackingStatus, + getEventStatus: Platform.getEventTrackingStatus, + setSiteStatus: Platform.setSiteTrackingStatus, + setEventStatus: Platform.setEventTrackingStatus, + trackEvent: Platform.trackEvent, + listWhitelist: Platform.listWhitelistedDomains, + addWhitelist: Platform.addWhitelistedDomain, + removeWhitelist: Platform.removeWhitelistedDomain, + }, + scores: { + list: Platform.listScores, + }, + emailActivities: { + list: Platform.listEmailActivities, + }, + brandings: { + get: Platform.getBranding, + update: Platform.updateBranding, + }, + configs: { + update: Platform.updateConfig, + }, + products: { + search: Platform.searchProducts, + get: Platform.getProduct, + create: Platform.createProduct, + update: Platform.updateProduct, + delete: Platform.removeProduct, + upsertBulk: Platform.upsertProductsBulk, + }, + orders: { + upsertBulk: Platform.upsertOrdersBulk, + upsertBulkAsync: Platform.upsertOrdersBulkAsync, + }, + recurringPayments: { + search: Platform.searchRecurringPayments, + upsertBulk: Platform.upsertRecurringPaymentsBulk, + }, + browseSessions: { + testEvent: Platform.testTrackingEvent, + search: Platform.searchBrowseSessions, + save: Platform.saveBrowseSession, + addToCart: Platform.addBrowseSessionToCart, + }, + smsBroadcasts: { + list: Platform.listSmsBroadcasts, + getMetrics: Platform.getSmsMetrics, + getSnapshot: Platform.getSmsMetricsSnapshot, + createSnapshot: Platform.createSmsMetricsSnapshot, + getFailures: Platform.getSmsFailures, + getRecipients: Platform.getSmsRecipients, + }, + smsCredits: { + get: Platform.getSmsCredits, + }, + smsBroadcastLists: { + list: Platform.listSmsBroadcastLists, + }, + addressGroups: { + delete: Platform.removeAddressGroup, + }, + contactTasks: { + create: Deals.createContactTask, + find: Deals.findContactTask, + }, + segmentsV2: { + create: SegmentsV2.create, + get: SegmentsV2.get, + update: SegmentsV2.update, + delete: SegmentsV2.remove, + getAtTimestamp: SegmentsV2.getAtTimestamp, + revertToTimestamp: SegmentsV2.revertToTimestamp, + recentCounts: SegmentsV2.recentCounts, + countHistory: SegmentsV2.countHistory, + countAtTimestamp: SegmentsV2.countAtTimestamp, + match: SegmentsV2.match, + matchByExternalId: SegmentsV2.matchByExternalId, + matchAll: SegmentsV2.matchAll, + matchAllResult: SegmentsV2.matchAllResult, + matchSomeResult: SegmentsV2.matchSomeResult, + }, + taskReminders: { + create: Platform.createTaskReminder, + }, +} as const; + +const I = ActiveCampaignEndpointInputSchemas; +const O = ActiveCampaignEndpointOutputSchemas; + +export const activecampaignEndpointSchemas = { + 'contacts.list': { input: I.contactsList, output: O.contactsList }, + 'contacts.get': { input: I.contactsGet, output: O.contactsGet }, + 'contacts.find': { input: I.contactsFind, output: O.contactsFind }, + 'contacts.createOrUpdate': { + input: I.contactsCreateOrUpdate, + output: O.contactsCreateOrUpdate, + }, + 'contacts.update': { input: I.contactsUpdate, output: O.contactsUpdate }, + 'contacts.delete': { input: I.contactsDelete, output: O.contactsDelete }, + 'contacts.getLists': { + input: I.contactsGetLists, + output: O.contactsGetLists, + }, + 'contacts.getTags': { input: I.contactsGetTags, output: O.contactsGetTags }, + 'contacts.getFieldValues': { + input: I.contactsGetFieldValues, + output: O.contactsGetFieldValues, + }, + 'contacts.getAutomations': { + input: I.contactsGetAutomations, + output: O.contactsGetAutomations, + }, + 'contacts.getGeoIps': { + input: I.contactsGetGeoIps, + output: O.contactsGetGeoIps, + }, + 'contacts.getScoreValues': { + input: I.contactsGetScoreValues, + output: O.contactsGetScoreValues, + }, + 'contacts.getDeals': { + input: I.contactsGetDeals, + output: O.contactsGetDeals, + }, + 'lists.list': { input: I.listsList, output: O.listsList }, + 'lists.get': { input: I.listsGet, output: O.listsGet }, + 'lists.create': { input: I.listsCreate, output: O.listsCreate }, + 'lists.delete': { input: I.listsDelete, output: O.listsDelete }, + 'lists.updateSubscription': { + input: I.listsUpdateSubscription, + output: O.listsUpdateSubscription, + }, + 'contactLists.list': { + input: I.contactListsList, + output: O.contactListsList, + }, + 'tags.list': { input: I.tagsList, output: O.tagsList }, + 'tags.get': { input: I.tagsGet, output: O.tagsGet }, + 'tags.create': { input: I.tagsCreate, output: O.tagsCreate }, + 'tags.update': { input: I.tagsUpdate, output: O.tagsUpdate }, + 'tags.delete': { input: I.tagsDelete, output: O.tagsDelete }, + 'tags.addToContact': { + input: I.tagsAddToContact, + output: O.tagsAddToContact, + }, + 'tags.removeFromContact': { + input: I.tagsRemoveFromContact, + output: O.tagsRemoveFromContact, + }, + 'contactTags.list': { input: I.contactTagsList, output: O.contactTagsList }, + 'fields.list': { input: I.fieldsList, output: O.fieldsList }, + 'fields.get': { input: I.fieldsGet, output: O.fieldsGet }, + 'fields.create': { input: I.fieldsCreate, output: O.fieldsCreate }, + 'fields.update': { input: I.fieldsUpdate, output: O.fieldsUpdate }, + 'fields.delete': { input: I.fieldsDelete, output: O.fieldsDelete }, + 'fieldOptions.createBulk': { + input: I.fieldOptionsCreateBulk, + output: O.fieldOptionsCreateBulk, + }, + 'fieldValues.list': { input: I.fieldValuesList, output: O.fieldValuesList }, + 'fieldValues.get': { input: I.fieldValuesGet, output: O.fieldValuesGet }, + 'fieldValues.setForContact': { + input: I.fieldValuesSetForContact, + output: O.fieldValuesSetForContact, + }, + 'fieldValues.update': { + input: I.fieldValuesUpdate, + output: O.fieldValuesUpdate, + }, + 'fieldValues.delete': { + input: I.fieldValuesDelete, + output: O.fieldValuesDelete, + }, + 'fieldRels.list': { input: I.fieldRelsList, output: O.fieldRelsList }, + 'fieldRels.create': { input: I.fieldRelsCreate, output: O.fieldRelsCreate }, + 'fieldRels.delete': { input: I.fieldRelsDelete, output: O.fieldRelsDelete }, + 'groupMembers.list': { + input: I.groupMembersList, + output: O.groupMembersList, + }, + 'groupMembers.create': { + input: I.groupMembersCreate, + output: O.groupMembersCreate, + }, + 'groupMembers.update': { + input: I.groupMembersUpdate, + output: O.groupMembersUpdate, + }, + 'groupMembers.delete': { + input: I.groupMembersDelete, + output: O.groupMembersDelete, + }, + 'contacts.getLogs': { input: I.contactsGetLogs, output: O.contactsGetLogs }, + 'contacts.getTrackingLogs': { + input: I.contactsGetTrackingLogs, + output: O.contactsGetTrackingLogs, + }, + 'contacts.getGoals': { + input: I.contactsGetGoals, + output: O.contactsGetGoals, + }, + 'contacts.getAccountContacts': { + input: I.contactsGetAccountContacts, + output: O.contactsGetAccountContacts, + }, + 'contacts.getNotes': { + input: I.contactsGetNotes, + output: O.contactsGetNotes, + }, + 'contacts.getData': { input: I.contactsGetData, output: O.contactsGetData }, + 'contacts.getOrganization': { + input: I.contactsGetOrganization, + output: O.contactsGetOrganization, + }, + 'contacts.getPlusAppend': { + input: I.contactsGetPlusAppend, + output: O.contactsGetPlusAppend, + }, + 'activities.list': { input: I.activitiesList, output: O.activitiesList }, + 'imports.createBulk': { + input: I.importsCreateBulk, + output: O.importsCreateBulk, + }, + 'imports.list': { input: I.importsList, output: O.importsList }, + 'imports.getStatus': { + input: I.importsGetStatus, + output: O.importsGetStatus, + }, + 'listGroups.create': { + input: I.listGroupsCreate, + output: O.listGroupsCreate, + }, + 'deals.list': { input: I.dealsList, output: O.dealsList }, + 'deals.listFiltered': { + input: I.dealsListFiltered, + output: O.dealsListFiltered, + }, + 'deals.get': { input: I.dealsGet, output: O.dealsGet }, + 'deals.update': { input: I.dealsUpdate, output: O.dealsUpdate }, + 'deals.delete': { input: I.dealsDelete, output: O.dealsDelete }, + 'deals.updateOwnersBulk': { + input: I.dealsUpdateOwnersBulk, + output: O.dealsUpdateOwnersBulk, + }, + 'dealGroups.list': { input: I.dealGroupsList, output: O.dealGroupsList }, + 'dealGroups.get': { input: I.dealGroupsGet, output: O.dealGroupsGet }, + 'dealGroups.create': { + input: I.dealGroupsCreate, + output: O.dealGroupsCreate, + }, + 'dealGroups.update': { + input: I.dealGroupsUpdate, + output: O.dealGroupsUpdate, + }, + 'dealGroups.delete': { + input: I.dealGroupsDelete, + output: O.dealGroupsDelete, + }, + 'dealStages.list': { input: I.dealStagesList, output: O.dealStagesList }, + 'dealStages.get': { input: I.dealStagesGet, output: O.dealStagesGet }, + 'dealStages.create': { + input: I.dealStagesCreate, + output: O.dealStagesCreate, + }, + 'dealStages.update': { + input: I.dealStagesUpdate, + output: O.dealStagesUpdate, + }, + 'dealStages.delete': { + input: I.dealStagesDelete, + output: O.dealStagesDelete, + }, + 'dealStages.moveDeals': { + input: I.dealStagesMoveDeals, + output: O.dealStagesMoveDeals, + }, + 'dealStages.deleteWithDeals': { + input: I.dealStagesDeleteWithDeals, + output: O.dealStagesDeleteWithDeals, + }, + 'dealTasks.list': { input: I.dealTasksList, output: O.dealTasksList }, + 'dealTasks.get': { input: I.dealTasksGet, output: O.dealTasksGet }, + 'dealTasks.create': { input: I.dealTasksCreate, output: O.dealTasksCreate }, + 'dealTasks.update': { input: I.dealTasksUpdate, output: O.dealTasksUpdate }, + 'dealTasks.delete': { input: I.dealTasksDelete, output: O.dealTasksDelete }, + 'dealTaskTypes.list': { + input: I.dealTaskTypesList, + output: O.dealTaskTypesList, + }, + 'dealTaskTypes.get': { + input: I.dealTaskTypesGet, + output: O.dealTaskTypesGet, + }, + 'dealTaskTypes.create': { + input: I.dealTaskTypesCreate, + output: O.dealTaskTypesCreate, + }, + 'dealTaskTypes.update': { + input: I.dealTaskTypesUpdate, + output: O.dealTaskTypesUpdate, + }, + 'taskOutcomes.list': { + input: I.taskOutcomesList, + output: O.taskOutcomesList, + }, + 'taskOutcomes.get': { input: I.taskOutcomesGet, output: O.taskOutcomesGet }, + 'taskOutcomes.create': { + input: I.taskOutcomesCreate, + output: O.taskOutcomesCreate, + }, + 'dealRoles.list': { input: I.dealRolesList, output: O.dealRolesList }, + 'dealRoles.create': { input: I.dealRolesCreate, output: O.dealRolesCreate }, + 'dealRoles.delete': { input: I.dealRolesDelete, output: O.dealRolesDelete }, + 'contactDeals.list': { + input: I.contactDealsList, + output: O.contactDealsList, + }, + 'contactDeals.get': { input: I.contactDealsGet, output: O.contactDealsGet }, + 'contactDeals.create': { + input: I.contactDealsCreate, + output: O.contactDealsCreate, + }, + 'contactDeals.update': { + input: I.contactDealsUpdate, + output: O.contactDealsUpdate, + }, + 'contactDeals.delete': { + input: I.contactDealsDelete, + output: O.contactDealsDelete, + }, + 'dealCustomFieldMeta.list': { + input: I.dealCustomFieldMetaList, + output: O.dealCustomFieldMetaList, + }, + 'dealCustomFieldMeta.get': { + input: I.dealCustomFieldMetaGet, + output: O.dealCustomFieldMetaGet, + }, + 'dealCustomFieldMeta.create': { + input: I.dealCustomFieldMetaCreate, + output: O.dealCustomFieldMetaCreate, + }, + 'dealCustomFieldMeta.update': { + input: I.dealCustomFieldMetaUpdate, + output: O.dealCustomFieldMetaUpdate, + }, + 'dealCustomFieldMeta.delete': { + input: I.dealCustomFieldMetaDelete, + output: O.dealCustomFieldMetaDelete, + }, + 'dealCustomFieldData.list': { + input: I.dealCustomFieldDataList, + output: O.dealCustomFieldDataList, + }, + 'dealCustomFieldData.get': { + input: I.dealCustomFieldDataGet, + output: O.dealCustomFieldDataGet, + }, + 'dealCustomFieldData.update': { + input: I.dealCustomFieldDataUpdate, + output: O.dealCustomFieldDataUpdate, + }, + 'dealCustomFieldData.delete': { + input: I.dealCustomFieldDataDelete, + output: O.dealCustomFieldDataDelete, + }, + 'dealActivities.list': { + input: I.dealActivitiesList, + output: O.dealActivitiesList, + }, + 'accounts.list': { input: I.accountsList, output: O.accountsList }, + 'accounts.get': { input: I.accountsGet, output: O.accountsGet }, + 'accounts.create': { input: I.accountsCreate, output: O.accountsCreate }, + 'accounts.update': { input: I.accountsUpdate, output: O.accountsUpdate }, + 'accounts.delete': { input: I.accountsDelete, output: O.accountsDelete }, + 'accounts.upsert': { input: I.accountsUpsert, output: O.accountsUpsert }, + 'accounts.deleteBulk': { + input: I.accountsDeleteBulk, + output: O.accountsDeleteBulk, + }, + 'accountContacts.list': { + input: I.accountContactsList, + output: O.accountContactsList, + }, + 'accountContacts.get': { + input: I.accountContactsGet, + output: O.accountContactsGet, + }, + 'accountContacts.create': { + input: I.accountContactsCreate, + output: O.accountContactsCreate, + }, + 'accountContacts.update': { + input: I.accountContactsUpdate, + output: O.accountContactsUpdate, + }, + 'accountContacts.delete': { + input: I.accountContactsDelete, + output: O.accountContactsDelete, + }, + 'accountCustomFieldMeta.list': { + input: I.accountCustomFieldMetaList, + output: O.accountCustomFieldMetaList, + }, + 'accountCustomFieldMeta.get': { + input: I.accountCustomFieldMetaGet, + output: O.accountCustomFieldMetaGet, + }, + 'accountCustomFieldMeta.create': { + input: I.accountCustomFieldMetaCreate, + output: O.accountCustomFieldMetaCreate, + }, + 'accountCustomFieldMeta.update': { + input: I.accountCustomFieldMetaUpdate, + output: O.accountCustomFieldMetaUpdate, + }, + 'accountCustomFieldMeta.delete': { + input: I.accountCustomFieldMetaDelete, + output: O.accountCustomFieldMetaDelete, + }, + 'accountCustomFieldData.list': { + input: I.accountCustomFieldDataList, + output: O.accountCustomFieldDataList, + }, + 'accountCustomFieldData.get': { + input: I.accountCustomFieldDataGet, + output: O.accountCustomFieldDataGet, + }, + 'accountCustomFieldData.create': { + input: I.accountCustomFieldDataCreate, + output: O.accountCustomFieldDataCreate, + }, + 'accountCustomFieldData.update': { + input: I.accountCustomFieldDataUpdate, + output: O.accountCustomFieldDataUpdate, + }, + 'accountCustomFieldData.delete': { + input: I.accountCustomFieldDataDelete, + output: O.accountCustomFieldDataDelete, + }, + 'accountCustomFieldData.createBulk': { + input: I.accountCustomFieldDataCreateBulk, + output: O.accountCustomFieldDataCreateBulk, + }, + 'accountCustomFieldData.updateBulk': { + input: I.accountCustomFieldDataUpdateBulk, + output: O.accountCustomFieldDataUpdateBulk, + }, + 'notes.list': { input: I.notesList, output: O.notesList }, + 'notes.get': { input: I.notesGet, output: O.notesGet }, + 'notes.create': { input: I.notesCreate, output: O.notesCreate }, + 'notes.update': { input: I.notesUpdate, output: O.notesUpdate }, + 'notes.delete': { input: I.notesDelete, output: O.notesDelete }, + 'notes.addToContact': { + input: I.notesAddToContact, + output: O.notesAddToContact, + }, + 'campaigns.list': { input: I.campaignsList, output: O.campaignsList }, + 'campaigns.get': { input: I.campaignsGet, output: O.campaignsGet }, + 'campaigns.create': { input: I.campaignsCreate, output: O.campaignsCreate }, + 'campaigns.update': { input: I.campaignsUpdate, output: O.campaignsUpdate }, + 'campaigns.duplicate': { + input: I.campaignsDuplicate, + output: O.campaignsDuplicate, + }, + 'campaigns.getLinks': { + input: I.campaignsGetLinks, + output: O.campaignsGetLinks, + }, + 'campaigns.getMessages': { + input: I.campaignsGetMessages, + output: O.campaignsGetMessages, + }, + 'campaigns.getAutomations': { + input: I.campaignsGetAutomations, + output: O.campaignsGetAutomations, + }, + 'campaigns.getAutomationLists': { + input: I.campaignsGetAutomationLists, + output: O.campaignsGetAutomationLists, + }, + 'campaigns.getUser': { + input: I.campaignsGetUser, + output: O.campaignsGetUser, + }, + 'messages.list': { input: I.messagesList, output: O.messagesList }, + 'messages.get': { input: I.messagesGet, output: O.messagesGet }, + 'messages.create': { input: I.messagesCreate, output: O.messagesCreate }, + 'messages.update': { input: I.messagesUpdate, output: O.messagesUpdate }, + 'messages.delete': { input: I.messagesDelete, output: O.messagesDelete }, + 'savedResponses.list': { + input: I.savedResponsesList, + output: O.savedResponsesList, + }, + 'savedResponses.get': { + input: I.savedResponsesGet, + output: O.savedResponsesGet, + }, + 'savedResponses.create': { + input: I.savedResponsesCreate, + output: O.savedResponsesCreate, + }, + 'savedResponses.update': { + input: I.savedResponsesUpdate, + output: O.savedResponsesUpdate, + }, + 'savedResponses.delete': { + input: I.savedResponsesDelete, + output: O.savedResponsesDelete, + }, + 'forms.list': { input: I.formsList, output: O.formsList }, + 'forms.get': { input: I.formsGet, output: O.formsGet }, + 'forms.delete': { input: I.formsDelete, output: O.formsDelete }, + 'forms.createOptin': { + input: I.formsCreateOptin, + output: O.formsCreateOptin, + }, + 'personalizations.list': { + input: I.personalizationsList, + output: O.personalizationsList, + }, + 'personalizations.get': { + input: I.personalizationsGet, + output: O.personalizationsGet, + }, + 'personalizations.create': { + input: I.personalizationsCreate, + output: O.personalizationsCreate, + }, + 'personalizations.update': { + input: I.personalizationsUpdate, + output: O.personalizationsUpdate, + }, + 'personalizations.delete': { + input: I.personalizationsDelete, + output: O.personalizationsDelete, + }, + 'personalizations.deleteBulk': { + input: I.personalizationsDeleteBulk, + output: O.personalizationsDeleteBulk, + }, + 'personalizations.lock': { + input: I.personalizationsLock, + output: O.personalizationsLock, + }, + 'personalizations.unlock': { + input: I.personalizationsUnlock, + output: O.personalizationsUnlock, + }, + 'templates.get': { input: I.templatesGet, output: O.templatesGet }, + 'templates.createShareLink': { + input: I.templatesCreateShareLink, + output: O.templatesCreateShareLink, + }, + 'automations.list': { input: I.automationsList, output: O.automationsList }, + 'contactAutomations.list': { + input: I.contactAutomationsList, + output: O.contactAutomationsList, + }, + 'contactAutomations.get': { + input: I.contactAutomationsGet, + output: O.contactAutomationsGet, + }, + 'contactAutomations.entryCounts': { + input: I.contactAutomationsEntryCounts, + output: O.contactAutomationsEntryCounts, + }, + 'contactAutomations.add': { + input: I.contactAutomationsAdd, + output: O.contactAutomationsAdd, + }, + 'contactAutomations.remove': { + input: I.contactAutomationsRemove, + output: O.contactAutomationsRemove, + }, + 'segments.list': { input: I.segmentsList, output: O.segmentsList }, + 'segments.get': { input: I.segmentsGet, output: O.segmentsGet }, + 'segments.create': { input: I.segmentsCreate, output: O.segmentsCreate }, + 'segments.update': { input: I.segmentsUpdate, output: O.segmentsUpdate }, + 'segments.delete': { input: I.segmentsDelete, output: O.segmentsDelete }, + 'segments.listAudiences': { + input: I.segmentsListAudiences, + output: O.segmentsListAudiences, + }, + 'connections.list': { input: I.connectionsList, output: O.connectionsList }, + 'connections.get': { input: I.connectionsGet, output: O.connectionsGet }, + 'connections.create': { + input: I.connectionsCreate, + output: O.connectionsCreate, + }, + 'connections.update': { + input: I.connectionsUpdate, + output: O.connectionsUpdate, + }, + 'connections.delete': { + input: I.connectionsDelete, + output: O.connectionsDelete, + }, + 'ecomCustomers.list': { + input: I.ecomCustomersList, + output: O.ecomCustomersList, + }, + 'ecomCustomers.get': { + input: I.ecomCustomersGet, + output: O.ecomCustomersGet, + }, + 'ecomCustomers.create': { + input: I.ecomCustomersCreate, + output: O.ecomCustomersCreate, + }, + 'ecomCustomers.update': { + input: I.ecomCustomersUpdate, + output: O.ecomCustomersUpdate, + }, + 'ecomCustomers.delete': { + input: I.ecomCustomersDelete, + output: O.ecomCustomersDelete, + }, + 'ecomOrders.list': { input: I.ecomOrdersList, output: O.ecomOrdersList }, + 'ecomOrders.get': { input: I.ecomOrdersGet, output: O.ecomOrdersGet }, + 'ecomOrders.create': { + input: I.ecomOrdersCreate, + output: O.ecomOrdersCreate, + }, + 'ecomOrders.update': { + input: I.ecomOrdersUpdate, + output: O.ecomOrdersUpdate, + }, + 'ecomOrders.delete': { + input: I.ecomOrdersDelete, + output: O.ecomOrdersDelete, + }, + 'ecomOrderProducts.list': { + input: I.ecomOrderProductsList, + output: O.ecomOrderProductsList, + }, + 'ecomOrderProducts.get': { + input: I.ecomOrderProductsGet, + output: O.ecomOrderProductsGet, + }, + 'customObjectSchemas.list': { + input: I.customObjectSchemasList, + output: O.customObjectSchemasList, + }, + 'customObjectSchemas.get': { + input: I.customObjectSchemasGet, + output: O.customObjectSchemasGet, + }, + 'customObjectSchemas.create': { + input: I.customObjectSchemasCreate, + output: O.customObjectSchemasCreate, + }, + 'customObjectSchemas.update': { + input: I.customObjectSchemasUpdate, + output: O.customObjectSchemasUpdate, + }, + 'customObjectSchemas.delete': { + input: I.customObjectSchemasDelete, + output: O.customObjectSchemasDelete, + }, + 'customObjectRecords.list': { + input: I.customObjectRecordsList, + output: O.customObjectRecordsList, + }, + 'customObjectRecords.upsert': { + input: I.customObjectRecordsUpsert, + output: O.customObjectRecordsUpsert, + }, + 'customObjectRecords.get': { + input: I.customObjectRecordsGet, + output: O.customObjectRecordsGet, + }, + 'customObjectRecords.getByExternalId': { + input: I.customObjectRecordsGetByExternalId, + output: O.customObjectRecordsGetByExternalId, + }, + 'customObjectRecords.delete': { + input: I.customObjectRecordsDelete, + output: O.customObjectRecordsDelete, + }, + 'customObjectRecords.deleteByExternalId': { + input: I.customObjectRecordsDeleteByExternalId, + output: O.customObjectRecordsDeleteByExternalId, + }, + 'webhooks.list': { input: I.webhooksList, output: O.webhooksList }, + 'webhooks.get': { input: I.webhooksGet, output: O.webhooksGet }, + 'webhooks.create': { input: I.webhooksCreate, output: O.webhooksCreate }, + 'webhooks.update': { input: I.webhooksUpdate, output: O.webhooksUpdate }, + 'webhooks.delete': { input: I.webhooksDelete, output: O.webhooksDelete }, + 'users.list': { input: I.usersList, output: O.usersList }, + 'users.get': { input: I.usersGet, output: O.usersGet }, + 'users.create': { input: I.usersCreate, output: O.usersCreate }, + 'users.update': { input: I.usersUpdate, output: O.usersUpdate }, + 'users.delete': { input: I.usersDelete, output: O.usersDelete }, + 'users.getMe': { input: I.usersGetMe, output: O.usersGetMe }, + 'users.getByUsername': { + input: I.usersGetByUsername, + output: O.usersGetByUsername, + }, + 'groups.list': { input: I.groupsList, output: O.groupsList }, + 'groups.get': { input: I.groupsGet, output: O.groupsGet }, + 'groups.create': { input: I.groupsCreate, output: O.groupsCreate }, + 'groups.update': { input: I.groupsUpdate, output: O.groupsUpdate }, + 'groups.delete': { input: I.groupsDelete, output: O.groupsDelete }, + 'groupLimits.list': { input: I.groupLimitsList, output: O.groupLimitsList }, + 'addresses.list': { input: I.addressesList, output: O.addressesList }, + 'addresses.get': { input: I.addressesGet, output: O.addressesGet }, + 'addresses.create': { input: I.addressesCreate, output: O.addressesCreate }, + 'addresses.update': { input: I.addressesUpdate, output: O.addressesUpdate }, + 'addresses.delete': { input: I.addressesDelete, output: O.addressesDelete }, + 'calendars.list': { input: I.calendarsList, output: O.calendarsList }, + 'calendars.get': { input: I.calendarsGet, output: O.calendarsGet }, + 'calendars.create': { input: I.calendarsCreate, output: O.calendarsCreate }, + 'calendars.update': { input: I.calendarsUpdate, output: O.calendarsUpdate }, + 'calendars.delete': { input: I.calendarsDelete, output: O.calendarsDelete }, + 'eventTrackingEvents.list': { + input: I.eventTrackingEventsList, + output: O.eventTrackingEventsList, + }, + 'eventTrackingEvents.create': { + input: I.eventTrackingEventsCreate, + output: O.eventTrackingEventsCreate, + }, + 'eventTrackingEvents.delete': { + input: I.eventTrackingEventsDelete, + output: O.eventTrackingEventsDelete, + }, + 'tracking.getSiteStatus': { + input: I.trackingGetSiteStatus, + output: O.trackingGetSiteStatus, + }, + 'tracking.getEventStatus': { + input: I.trackingGetEventStatus, + output: O.trackingGetEventStatus, + }, + 'tracking.setSiteStatus': { + input: I.trackingSetSiteStatus, + output: O.trackingSetSiteStatus, + }, + 'tracking.setEventStatus': { + input: I.trackingSetEventStatus, + output: O.trackingSetEventStatus, + }, + 'tracking.trackEvent': { + input: I.trackingTrackEvent, + output: O.trackingTrackEvent, + }, + 'tracking.listWhitelist': { + input: I.trackingListWhitelist, + output: O.trackingListWhitelist, + }, + 'tracking.addWhitelist': { + input: I.trackingAddWhitelist, + output: O.trackingAddWhitelist, + }, + 'tracking.removeWhitelist': { + input: I.trackingRemoveWhitelist, + output: O.trackingRemoveWhitelist, + }, + 'scores.list': { input: I.scoresList, output: O.scoresList }, + 'emailActivities.list': { + input: I.emailActivitiesList, + output: O.emailActivitiesList, + }, + 'brandings.get': { input: I.brandingsGet, output: O.brandingsGet }, + 'brandings.update': { input: I.brandingsUpdate, output: O.brandingsUpdate }, + 'configs.update': { input: I.configsUpdate, output: O.configsUpdate }, + 'products.search': { input: I.productsSearch, output: O.productsSearch }, + 'products.get': { input: I.productsGet, output: O.productsGet }, + 'products.create': { input: I.productsCreate, output: O.productsCreate }, + 'products.update': { input: I.productsUpdate, output: O.productsUpdate }, + 'products.delete': { input: I.productsDelete, output: O.productsDelete }, + 'products.upsertBulk': { + input: I.productsUpsertBulk, + output: O.productsUpsertBulk, + }, + 'orders.upsertBulk': { + input: I.ordersUpsertBulk, + output: O.ordersUpsertBulk, + }, + 'orders.upsertBulkAsync': { + input: I.ordersUpsertBulkAsync, + output: O.ordersUpsertBulkAsync, + }, + 'recurringPayments.search': { + input: I.recurringPaymentsSearch, + output: O.recurringPaymentsSearch, + }, + 'recurringPayments.upsertBulk': { + input: I.recurringPaymentsUpsertBulk, + output: O.recurringPaymentsUpsertBulk, + }, + 'browseSessions.search': { + input: I.browseSessionsSearch, + output: O.browseSessionsSearch, + }, + 'browseSessions.save': { + input: I.browseSessionsSave, + output: O.browseSessionsSave, + }, + 'browseSessions.addToCart': { + input: I.browseSessionsAddToCart, + output: O.browseSessionsAddToCart, + }, + 'smsBroadcasts.list': { + input: I.smsBroadcastsList, + output: O.smsBroadcastsList, + }, + 'smsBroadcasts.getMetrics': { + input: I.smsBroadcastsGetMetrics, + output: O.smsBroadcastsGetMetrics, + }, + 'smsBroadcasts.getSnapshot': { + input: I.smsBroadcastsGetSnapshot, + output: O.smsBroadcastsGetSnapshot, + }, + 'smsBroadcasts.createSnapshot': { + input: I.smsBroadcastsCreateSnapshot, + output: O.smsBroadcastsCreateSnapshot, + }, + 'smsBroadcasts.getFailures': { + input: I.smsBroadcastsGetFailures, + output: O.smsBroadcastsGetFailures, + }, + 'smsBroadcasts.getRecipients': { + input: I.smsBroadcastsGetRecipients, + output: O.smsBroadcastsGetRecipients, + }, + 'smsCredits.get': { input: I.smsCreditsGet, output: O.smsCreditsGet }, + 'tracking.getCode': { input: I.trackingGetCode, output: O.trackingGetCode }, + 'smsBroadcastLists.list': { + input: I.smsBroadcastListsList, + output: O.smsBroadcastListsList, + }, + 'addressGroups.delete': { + input: I.addressGroupsDelete, + output: O.addressGroupsDelete, + }, + 'ecomOrders.find': { input: I.ecomOrdersFind, output: O.ecomOrdersFind }, + 'ecomOrders.upsert': { + input: I.ecomOrdersUpsert, + output: O.ecomOrdersUpsert, + }, + 'ecomOrderProducts.listForOrder': { + input: I.ecomOrderProductsListForOrder, + output: O.ecomOrderProductsListForOrder, + }, + 'notes.createForAccount': { + input: I.notesCreateForAccount, + output: O.notesCreateForAccount, + }, + 'notes.createForDeal': { + input: I.notesCreateForDeal, + output: O.notesCreateForDeal, + }, + 'notes.updateForAccount': { + input: I.notesUpdateForAccount, + output: O.notesUpdateForAccount, + }, + 'notes.updateForDeal': { + input: I.notesUpdateForDeal, + output: O.notesUpdateForDeal, + }, + 'contactTasks.create': { + input: I.contactTasksCreate, + output: O.contactTasksCreate, + }, + 'contactTasks.find': { + input: I.contactTasksFind, + output: O.contactTasksFind, + }, + 'segmentsV2.create': { + input: I.segmentsV2Create, + output: O.segmentsV2Create, + }, + 'segmentsV2.get': { + input: I.segmentsV2Get, + output: O.segmentsV2Get, + }, + 'segmentsV2.update': { + input: I.segmentsV2Update, + output: O.segmentsV2Update, + }, + 'segmentsV2.delete': { + input: I.segmentsV2Delete, + output: O.segmentsV2Delete, + }, + 'segmentsV2.getAtTimestamp': { + input: I.segmentsV2GetAtTimestamp, + output: O.segmentsV2GetAtTimestamp, + }, + 'segmentsV2.revertToTimestamp': { + input: I.segmentsV2RevertToTimestamp, + output: O.segmentsV2RevertToTimestamp, + }, + 'segmentsV2.recentCounts': { + input: I.segmentsV2RecentCounts, + output: O.segmentsV2RecentCounts, + }, + 'segmentsV2.countHistory': { + input: I.segmentsV2CountHistory, + output: O.segmentsV2CountHistory, + }, + 'segmentsV2.countAtTimestamp': { + input: I.segmentsV2CountAtTimestamp, + output: O.segmentsV2CountAtTimestamp, + }, + 'segmentsV2.match': { + input: I.segmentsV2Match, + output: O.segmentsV2Match, + }, + 'segmentsV2.matchByExternalId': { + input: I.segmentsV2MatchByExternalId, + output: O.segmentsV2MatchByExternalId, + }, + 'segmentsV2.matchAll': { + input: I.segmentsV2MatchAll, + output: O.segmentsV2MatchAll, + }, + 'segmentsV2.matchAllResult': { + input: I.segmentsV2MatchAllResult, + output: O.segmentsV2MatchAllResult, + }, + 'segmentsV2.matchSomeResult': { + input: I.segmentsV2MatchSomeResult, + output: O.segmentsV2MatchSomeResult, + }, + 'taskReminders.create': { + input: I.taskRemindersCreate, + output: O.taskRemindersCreate, + }, + 'customObjectSchemas.createChild': { + input: I.customObjectSchemasCreateChild, + output: O.customObjectSchemasCreateChild, + }, + 'imports.listAggregate': { + input: I.importsListAggregate, + output: O.importsListAggregate, + }, + 'browseSessions.testEvent': { + input: I.browseSessionsTestEvent, + output: O.browseSessionsTestEvent, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof activecampaignEndpointsNested +>; + +/** + * ActiveCampaign does offer webhooks, but this plugin exposes no Corsair + * triggers - the webhook operations in the catalog manage subscriptions rather + * than deliver events here. The tree is declared empty so the plugin still + * satisfies the webhook-shaped generics, and the matcher returns false so no + * incoming request is ever routed to this plugin. + */ +const activecampaignWebhooksNested = {} as const; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const activecampaignEndpointMeta = { + 'contacts.list': { + riskLevel: 'read', + description: 'List contacts with pagination and filters', + }, + 'contacts.get': { + riskLevel: 'read', + description: 'Retrieve a contact by its ID', + }, + 'contacts.find': { + riskLevel: 'read', + description: 'Find a contact by email address', + }, + 'contacts.createOrUpdate': { + riskLevel: 'write', + description: 'Create a contact, or update it if the email already exists', + }, + 'contacts.update': { + riskLevel: 'write', + description: 'Update an existing contact by its ID', + }, + 'contacts.delete': { + riskLevel: 'destructive', + description: 'Delete a contact by its ID', + }, + 'contacts.getLists': { + riskLevel: 'read', + description: 'List the list memberships of a contact', + }, + 'contacts.getTags': { + riskLevel: 'read', + description: 'List the tags applied to a contact', + }, + 'contacts.getFieldValues': { + riskLevel: 'read', + description: 'List the custom field values of a contact', + }, + 'contacts.getAutomations': { + riskLevel: 'read', + description: 'List the automations a contact is enrolled in', + }, + 'contacts.getGeoIps': { + riskLevel: 'read', + description: 'List the geo IP records associated with a contact', + }, + 'contacts.getScoreValues': { + riskLevel: 'read', + description: 'List the score values of a contact', + }, + 'contacts.getDeals': { + riskLevel: 'read', + description: 'List the deals associated with a contact', + }, + 'lists.list': { + riskLevel: 'read', + description: 'List mailing lists with pagination', + }, + 'lists.get': { + riskLevel: 'read', + description: 'Retrieve a mailing list by its ID', + }, + 'lists.create': { + riskLevel: 'write', + description: 'Create a new mailing list', + }, + 'lists.delete': { + riskLevel: 'destructive', + description: 'Delete a mailing list by its ID', + }, + 'lists.updateSubscription': { + riskLevel: 'write', + description: 'Subscribe or unsubscribe a contact to or from a list', + }, + 'contactLists.list': { + riskLevel: 'read', + description: 'List all contact-to-list memberships', + }, + 'tags.list': { + riskLevel: 'read', + description: 'List tags with pagination and search', + }, + 'tags.get': { + riskLevel: 'read', + description: 'Retrieve a tag by its ID', + }, + 'tags.create': { + riskLevel: 'write', + description: 'Create a new tag', + }, + 'tags.update': { + riskLevel: 'write', + description: 'Update an existing tag by its ID', + }, + 'tags.delete': { + riskLevel: 'destructive', + description: 'Delete a tag by its ID', + }, + 'tags.addToContact': { + riskLevel: 'write', + description: 'Apply a tag to a contact', + }, + 'tags.removeFromContact': { + riskLevel: 'destructive', + description: 'Remove a tag from a contact by its contactTag ID', + }, + 'contactTags.list': { + riskLevel: 'read', + description: 'List all contact-to-tag associations', + }, + 'fields.list': { + riskLevel: 'read', + description: 'List custom field definitions with pagination', + }, + 'fields.get': { + riskLevel: 'read', + description: 'Retrieve a custom field definition by its ID', + }, + 'fields.create': { + riskLevel: 'write', + description: 'Create a new custom contact field', + }, + 'fields.update': { + riskLevel: 'write', + description: 'Update an existing custom field definition', + }, + 'fields.delete': { + riskLevel: 'destructive', + description: 'Delete a custom field and every value stored against it', + }, + 'fieldOptions.createBulk': { + riskLevel: 'write', + description: 'Create options in bulk for a dropdown or listbox field', + }, + 'fieldValues.list': { + riskLevel: 'read', + description: 'List custom field values across all contacts', + }, + 'fieldValues.get': { + riskLevel: 'read', + description: 'Retrieve a single custom field value by its ID', + }, + 'fieldValues.setForContact': { + riskLevel: 'write', + description: 'Set a custom field value on a contact', + }, + 'fieldValues.update': { + riskLevel: 'write', + description: 'Update an existing custom field value by its ID', + }, + 'fieldValues.delete': { + riskLevel: 'destructive', + description: 'Delete a custom field value by its ID', + }, + 'fieldRels.list': { + riskLevel: 'read', + description: 'List relationships between custom fields and lists', + }, + 'fieldRels.create': { + riskLevel: 'write', + description: 'Associate a custom field with a list', + }, + 'fieldRels.delete': { + riskLevel: 'destructive', + description: 'Remove the association between a custom field and a list', + }, + 'groupMembers.list': { + riskLevel: 'read', + description: 'List which custom fields belong to which display groups', + }, + 'groupMembers.create': { + riskLevel: 'write', + description: 'Add a custom field to a display group so it becomes visible', + }, + 'groupMembers.update': { + riskLevel: 'write', + description: 'Change the display group or ordering of a custom field', + }, + 'groupMembers.delete': { + riskLevel: 'destructive', + description: 'Remove a custom field from its display group', + }, + 'contacts.getLogs': { + riskLevel: 'read', + description: 'List the activity log entries for a contact', + }, + 'contacts.getTrackingLogs': { + riskLevel: 'read', + description: 'List site and event tracking records for a contact', + }, + 'contacts.getGoals': { + riskLevel: 'read', + description: 'List the automation goals a contact has completed', + }, + 'contacts.getAccountContacts': { + riskLevel: 'read', + description: 'List the accounts a contact is associated with', + }, + 'contacts.getNotes': { + riskLevel: 'read', + description: 'List the notes attached to a contact', + }, + 'contacts.getData': { + riskLevel: 'read', + description: 'Retrieve the geographic and tracking data of a contact', + }, + 'contacts.getOrganization': { + riskLevel: 'read', + description: 'Retrieve the organization a contact belongs to', + }, + 'contacts.getPlusAppend': { + riskLevel: 'read', + description: 'Retrieve third-party enrichment data for a contact', + }, + 'activities.list': { + riskLevel: 'read', + description: 'List account activity, optionally narrowed to one contact', + }, + 'imports.createBulk': { + riskLevel: 'write', + description: + 'Queue up to 250 contacts per call (payload under 400 KB) for asynchronous import', + }, + 'imports.list': { + riskLevel: 'read', + description: 'List outstanding and recently completed import batches', + }, + 'imports.getStatus': { + riskLevel: 'read', + description: 'Retrieve the progress of a single import batch by its ID', + }, + 'listGroups.create': { + riskLevel: 'write', + description: 'Grant a user group permissions over a mailing list', + }, + 'deals.list': { + riskLevel: 'read', + description: 'List deals with pagination and filters', + }, + 'deals.listFiltered': { + riskLevel: 'read', + description: + 'Search deals by title or filter by stage, pipeline, owner or status', + }, + 'deals.get': { + riskLevel: 'read', + description: 'Retrieve a deal by its ID', + }, + 'deals.update': { + riskLevel: 'write', + description: 'Update an existing deal by its ID', + }, + 'deals.delete': { + riskLevel: 'destructive', + description: 'Delete a deal by its ID', + }, + 'deals.updateOwnersBulk': { + riskLevel: 'write', + description: 'Reassign many deals to new owners in one request', + }, + 'dealGroups.list': { + riskLevel: 'read', + description: 'List deal pipelines, optionally filtered by title', + }, + 'dealGroups.get': { + riskLevel: 'read', + description: 'Retrieve a deal pipeline by its ID', + }, + 'dealGroups.create': { + riskLevel: 'write', + description: 'Create a deal pipeline with its three default stages', + }, + 'dealGroups.update': { + riskLevel: 'write', + description: 'Update a deal pipeline by its ID', + }, + 'dealGroups.delete': { + riskLevel: 'destructive', + description: 'Delete a pipeline and every stage and deal in it', + }, + 'dealStages.list': { + riskLevel: 'read', + description: 'List deal pipeline stages', + }, + 'dealStages.get': { + riskLevel: 'read', + description: 'Retrieve a pipeline stage by its ID', + }, + 'dealStages.create': { + riskLevel: 'write', + description: 'Create a stage in a deal pipeline', + }, + 'dealStages.update': { + riskLevel: 'write', + description: 'Update a pipeline stage by its ID', + }, + 'dealStages.delete': { + riskLevel: 'destructive', + description: 'Delete a pipeline stage by its ID', + }, + 'dealStages.moveDeals': { + riskLevel: 'write', + description: 'Move every deal in one stage to another stage', + }, + 'dealStages.deleteWithDeals': { + riskLevel: 'destructive', + description: 'Delete a stage, optionally relocating its deals first', + }, + 'dealTasks.list': { + riskLevel: 'read', + description: 'List deal tasks with pagination', + }, + 'dealTasks.get': { + riskLevel: 'read', + description: 'Retrieve a deal task by its ID', + }, + 'dealTasks.create': { + riskLevel: 'write', + description: 'Create a task against a deal, contact or account', + }, + 'dealTasks.update': { + riskLevel: 'write', + description: 'Update an existing deal task by its ID', + }, + 'dealTasks.delete': { + riskLevel: 'destructive', + description: 'Delete a deal task by its ID', + }, + 'dealTaskTypes.list': { + riskLevel: 'read', + description: 'List the task types available for deals', + }, + 'dealTaskTypes.get': { + riskLevel: 'read', + description: 'Retrieve a deal task type by its ID', + }, + 'dealTaskTypes.create': { + riskLevel: 'write', + description: 'Create a task type for categorising deal tasks', + }, + 'dealTaskTypes.update': { + riskLevel: 'write', + description: 'Update a deal task type by its ID', + }, + 'taskOutcomes.list': { + riskLevel: 'read', + description: 'List the outcomes that can be assigned to tasks', + }, + 'taskOutcomes.get': { + riskLevel: 'read', + description: 'Retrieve a task outcome by its ID', + }, + 'taskOutcomes.create': { + riskLevel: 'write', + description: 'Create a task outcome with an associated sentiment', + }, + 'dealRoles.list': { + riskLevel: 'read', + description: 'List the roles a contact can hold on a deal', + }, + 'dealRoles.create': { + riskLevel: 'write', + description: 'Create a deal role such as Decision Maker', + }, + 'dealRoles.delete': { + riskLevel: 'destructive', + description: 'Delete a deal role by its ID', + }, + 'contactDeals.list': { + riskLevel: 'read', + description: 'List secondary contacts associated with deals', + }, + 'contactDeals.get': { + riskLevel: 'read', + description: 'Retrieve a secondary contact association by its ID', + }, + 'contactDeals.create': { + riskLevel: 'write', + description: 'Add a secondary contact to a deal', + }, + 'contactDeals.update': { + riskLevel: 'write', + description: 'Update a secondary contact association', + }, + 'contactDeals.delete': { + riskLevel: 'destructive', + description: 'Remove a secondary contact from a deal', + }, + 'dealCustomFieldMeta.list': { + riskLevel: 'read', + description: 'List custom field definitions for deals', + }, + 'dealCustomFieldMeta.get': { + riskLevel: 'read', + description: 'Retrieve a deal custom field definition by its ID', + }, + 'dealCustomFieldMeta.create': { + riskLevel: 'write', + description: 'Create a custom field definition for deals', + }, + 'dealCustomFieldMeta.update': { + riskLevel: 'write', + description: 'Update a deal custom field definition', + }, + 'dealCustomFieldMeta.delete': { + riskLevel: 'destructive', + description: 'Delete a deal custom field definition', + }, + 'dealCustomFieldData.list': { + riskLevel: 'read', + description: 'List custom field values stored against deals', + }, + 'dealCustomFieldData.get': { + riskLevel: 'read', + description: 'Retrieve a deal custom field value by its ID', + }, + 'dealCustomFieldData.update': { + riskLevel: 'write', + description: 'Update a custom field value on a deal', + }, + 'dealCustomFieldData.delete': { + riskLevel: 'destructive', + description: 'Delete a custom field value from a deal', + }, + 'dealActivities.list': { + riskLevel: 'read', + description: 'List recent activity across deals', + }, + 'accounts.list': { + riskLevel: 'read', + description: 'List CRM accounts, optionally filtered by name', + }, + 'accounts.get': { + riskLevel: 'read', + description: 'Retrieve a CRM account by its ID', + }, + 'accounts.create': { + riskLevel: 'write', + description: 'Create a CRM account with a unique name', + }, + 'accounts.update': { + riskLevel: 'write', + description: 'Update an existing CRM account by its ID', + }, + 'accounts.delete': { + riskLevel: 'destructive', + description: 'Delete a CRM account and its associated data', + }, + 'accounts.upsert': { + riskLevel: 'write', + description: 'Create a CRM account, or update the one with the same name', + }, + 'accounts.deleteBulk': { + riskLevel: 'destructive', + description: 'Delete many CRM accounts in one request', + }, + 'accountContacts.list': { + riskLevel: 'read', + description: 'List associations between accounts and contacts', + }, + 'accountContacts.get': { + riskLevel: 'read', + description: 'Retrieve an account-contact association by its ID', + }, + 'accountContacts.create': { + riskLevel: 'write', + description: 'Link a contact to an account with an optional job title', + }, + 'accountContacts.update': { + riskLevel: 'write', + description: 'Update an account-contact association', + }, + 'accountContacts.delete': { + riskLevel: 'destructive', + description: 'Remove the link between an account and a contact', + }, + 'accountCustomFieldMeta.list': { + riskLevel: 'read', + description: 'List custom field definitions for accounts', + }, + 'accountCustomFieldMeta.get': { + riskLevel: 'read', + description: 'Retrieve an account custom field definition by its ID', + }, + 'accountCustomFieldMeta.create': { + riskLevel: 'write', + description: 'Define a new custom field for accounts', + }, + 'accountCustomFieldMeta.update': { + riskLevel: 'write', + description: 'Update an account custom field definition', + }, + 'accountCustomFieldMeta.delete': { + riskLevel: 'destructive', + description: 'Delete an account custom field definition', + }, + 'accountCustomFieldData.list': { + riskLevel: 'read', + description: 'List custom field values stored against accounts', + }, + 'accountCustomFieldData.get': { + riskLevel: 'read', + description: 'Retrieve an account custom field value by its ID', + }, + 'accountCustomFieldData.create': { + riskLevel: 'write', + description: 'Set a custom field value on an account', + }, + 'accountCustomFieldData.update': { + riskLevel: 'write', + description: 'Update a custom field value on an account', + }, + 'accountCustomFieldData.delete': { + riskLevel: 'destructive', + description: 'Delete a custom field value from an account', + }, + 'accountCustomFieldData.createBulk': { + riskLevel: 'write', + description: 'Set many account custom field values in one request', + }, + 'accountCustomFieldData.updateBulk': { + riskLevel: 'write', + description: 'Update many account custom field values in one request', + }, + 'notes.list': { + riskLevel: 'read', + description: 'List notes across contacts, deals and accounts', + }, + 'notes.get': { + riskLevel: 'read', + description: 'Retrieve a note by its ID', + }, + 'notes.create': { + riskLevel: 'write', + description: 'Create a note against a contact, deal or account', + }, + 'notes.update': { + riskLevel: 'write', + description: 'Update the body of an existing note', + }, + 'notes.delete': { + riskLevel: 'destructive', + description: 'Delete a note by its ID', + }, + 'notes.addToContact': { + riskLevel: 'write', + description: 'Add a note to a contact identified by email address', + }, + 'campaigns.list': { + riskLevel: 'read', + description: 'List campaigns with pagination and filters', + }, + 'campaigns.get': { + riskLevel: 'read', + description: 'Retrieve a campaign by its ID with engagement metrics', + }, + 'campaigns.create': { + riskLevel: 'write', + description: 'Create a broadcast or automation campaign', + }, + 'campaigns.update': { + riskLevel: 'write', + description: 'Edit an existing campaign, such as its name', + }, + 'campaigns.duplicate': { + riskLevel: 'write', + description: 'Duplicate a campaign with its content and configuration', + }, + 'campaigns.getLinks': { + riskLevel: 'read', + description: 'List the tracked links belonging to a campaign', + }, + 'campaigns.getMessages': { + riskLevel: 'read', + description: 'List the messages attached to a campaign', + }, + 'campaigns.getAutomations': { + riskLevel: 'read', + description: 'List automations linked to a campaign', + }, + 'campaigns.getAutomationLists': { + riskLevel: 'read', + description: 'List the lists a campaign automation sends to', + }, + 'campaigns.getUser': { + riskLevel: 'read', + description: 'Retrieve the user who owns a campaign', + }, + 'messages.list': { + riskLevel: 'read', + description: 'List email messages with pagination', + }, + 'messages.get': { + riskLevel: 'read', + description: 'Retrieve an email message by its ID', + }, + 'messages.create': { + riskLevel: 'write', + description: 'Create an email message with subject, sender and content', + }, + 'messages.update': { + riskLevel: 'write', + description: 'Update an existing email message', + }, + 'messages.delete': { + riskLevel: 'destructive', + description: 'Delete an email message by its ID', + }, + 'savedResponses.list': { + riskLevel: 'read', + description: 'List saved response templates', + }, + 'savedResponses.get': { + riskLevel: 'read', + description: 'Retrieve a saved response by its ID', + }, + 'savedResponses.create': { + riskLevel: 'write', + description: 'Create a reusable saved response template', + }, + 'savedResponses.update': { + riskLevel: 'write', + description: 'Update a saved response template', + }, + 'savedResponses.delete': { + riskLevel: 'destructive', + description: 'Delete a saved response template', + }, + 'forms.list': { + riskLevel: 'read', + description: 'List forms with their field configuration', + }, + 'forms.get': { + riskLevel: 'read', + description: 'Retrieve a form by its ID', + }, + 'forms.delete': { + riskLevel: 'destructive', + description: 'Delete a form and its associated data', + }, + 'forms.createOptin': { + riskLevel: 'write', + description: 'Record a form opt-in on behalf of a contact', + }, + 'personalizations.list': { + riskLevel: 'read', + description: 'List personalization variables', + }, + 'personalizations.get': { + riskLevel: 'read', + description: 'Retrieve a personalization variable by its ID', + }, + 'personalizations.create': { + riskLevel: 'write', + description: 'Create a personalization variable', + }, + 'personalizations.update': { + riskLevel: 'write', + description: 'Edit an existing personalization variable', + }, + 'personalizations.delete': { + riskLevel: 'destructive', + description: 'Delete a personalization variable', + }, + 'personalizations.deleteBulk': { + riskLevel: 'destructive', + description: 'Delete many personalization variables at once', + }, + 'personalizations.lock': { + riskLevel: 'write', + description: 'Lock a personalization variable against edits', + }, + 'personalizations.unlock': { + riskLevel: 'write', + description: 'Unlock a personalization variable for editing', + }, + 'templates.get': { + riskLevel: 'read', + description: 'Retrieve a campaign template by its ID', + }, + 'templates.createShareLink': { + riskLevel: 'write', + description: 'Create a shareable link for a campaign template', + }, + 'automations.list': { + riskLevel: 'read', + description: 'List automation workflows', + }, + 'contactAutomations.list': { + riskLevel: 'read', + description: 'List contact enrolments across automations', + }, + 'contactAutomations.get': { + riskLevel: 'read', + description: 'Retrieve a contact automation enrolment by its ID', + }, + 'contactAutomations.entryCounts': { + riskLevel: 'read', + description: 'Count how many times a contact entered each automation', + }, + 'contactAutomations.add': { + riskLevel: 'write', + description: 'Enrol a contact in an automation by email address', + }, + 'contactAutomations.remove': { + riskLevel: 'destructive', + description: 'Remove a contact from an automation, one run or all', + }, + 'segments.list': { + riskLevel: 'read', + description: 'List contact segments', + }, + 'segments.get': { + riskLevel: 'read', + description: 'Retrieve a segment by its ID', + }, + 'segments.create': { + riskLevel: 'write', + description: 'Create a segment with filtering conditions', + }, + 'segments.update': { + riskLevel: 'write', + description: 'Update a segment definition', + }, + 'segments.delete': { + riskLevel: 'destructive', + description: 'Delete a segment and its history', + }, + 'segments.listAudiences': { + riskLevel: 'read', + description: 'List saved segment summaries, known as audiences', + }, + 'connections.list': { + riskLevel: 'read', + description: 'List Deep Data connections to external services', + }, + 'connections.get': { + riskLevel: 'read', + description: 'Retrieve a connection by its ID', + }, + 'connections.create': { + riskLevel: 'write', + description: 'Create a connection to an external e-commerce service', + }, + 'connections.update': { + riskLevel: 'write', + description: 'Update an existing connection', + }, + 'connections.delete': { + riskLevel: 'destructive', + description: 'Delete a connection by its ID', + }, + 'ecomCustomers.list': { + riskLevel: 'read', + description: 'List e-commerce customers with revenue metrics', + }, + 'ecomCustomers.get': { + riskLevel: 'read', + description: 'Retrieve an e-commerce customer by its ID', + }, + 'ecomCustomers.create': { + riskLevel: 'write', + description: 'Register an e-commerce customer against a connection', + }, + 'ecomCustomers.update': { + riskLevel: 'write', + description: 'Update an e-commerce customer record', + }, + 'ecomCustomers.delete': { + riskLevel: 'destructive', + description: 'Delete an e-commerce customer and its data', + }, + 'ecomOrders.list': { + riskLevel: 'read', + description: 'List e-commerce orders with pagination', + }, + 'ecomOrders.get': { + riskLevel: 'read', + description: 'Retrieve an e-commerce order by its ID', + }, + 'ecomOrders.create': { + riskLevel: 'write', + description: 'Record an e-commerce order for automation triggers', + }, + 'ecomOrders.update': { + riskLevel: 'write', + description: 'Update an existing e-commerce order', + }, + 'ecomOrders.delete': { + riskLevel: 'destructive', + description: 'Delete an e-commerce order by its ID', + }, + 'ecomOrderProducts.list': { + riskLevel: 'read', + description: 'List the products attached to e-commerce orders', + }, + 'ecomOrderProducts.get': { + riskLevel: 'read', + description: 'Retrieve an order product line by its ID', + }, + 'customObjectSchemas.list': { + riskLevel: 'read', + description: 'List custom object schema definitions', + }, + 'customObjectSchemas.get': { + riskLevel: 'read', + description: 'Retrieve a custom object schema by its ID', + }, + 'customObjectSchemas.create': { + riskLevel: 'write', + description: 'Create a custom object schema', + }, + 'customObjectSchemas.update': { + riskLevel: 'write', + description: 'Update a custom object schema or add field options', + }, + 'customObjectSchemas.delete': { + riskLevel: 'destructive', + description: 'Delete a custom object schema and all its records', + }, + 'customObjectRecords.list': { + riskLevel: 'read', + description: 'List the records belonging to a custom object schema', + }, + 'customObjectRecords.upsert': { + riskLevel: 'write', + description: 'Create or update a custom object record by external ID', + }, + 'customObjectRecords.get': { + riskLevel: 'read', + description: 'Retrieve a custom object record by its ID', + }, + 'customObjectRecords.getByExternalId': { + riskLevel: 'read', + description: 'Retrieve a custom object record by its external ID', + }, + 'customObjectRecords.delete': { + riskLevel: 'destructive', + description: 'Delete a custom object record by its ID', + }, + 'customObjectRecords.deleteByExternalId': { + riskLevel: 'destructive', + description: 'Delete a custom object record by its external ID', + }, + 'webhooks.list': { + riskLevel: 'read', + description: 'List configured webhook subscriptions', + }, + 'webhooks.get': { + riskLevel: 'read', + description: 'Retrieve a webhook subscription by its ID', + }, + 'webhooks.create': { + riskLevel: 'write', + description: 'Create a webhook subscription for account events', + }, + 'webhooks.update': { + riskLevel: 'write', + description: 'Update a webhook subscription', + }, + 'webhooks.delete': { + riskLevel: 'destructive', + description: 'Delete a webhook subscription by its ID', + }, + 'users.list': { + riskLevel: 'read', + description: 'List account users with pagination and sorting', + }, + 'users.get': { + riskLevel: 'read', + description: 'Retrieve an account user by their ID', + }, + 'users.create': { + riskLevel: 'write', + description: 'Create an account user who can sign in', + }, + 'users.update': { + riskLevel: 'write', + description: 'Update an account user, including group assignment', + }, + 'users.delete': { + riskLevel: 'destructive', + description: 'Delete an account user by their ID', + }, + 'users.getMe': { + riskLevel: 'read', + description: 'Retrieve the user the API token belongs to', + }, + 'users.getByUsername': { + riskLevel: 'read', + description: 'Retrieve an account user by their username', + }, + 'groups.list': { + riskLevel: 'read', + description: 'List permission groups with their settings', + }, + 'groups.get': { + riskLevel: 'read', + description: 'Retrieve a permission group by its ID', + }, + 'groups.create': { + riskLevel: 'write', + description: 'Create a permission group', + }, + 'groups.update': { + riskLevel: 'write', + description: 'Update a permission group title or description', + }, + 'groups.delete': { + riskLevel: 'destructive', + description: 'Delete a permission group by its ID', + }, + 'groupLimits.list': { + riskLevel: 'read', + description: 'List the resource limits configured per group', + }, + 'addresses.list': { + riskLevel: 'read', + description: 'List the company addresses used in campaigns', + }, + 'addresses.get': { + riskLevel: 'read', + description: 'Retrieve a company address by its ID', + }, + 'addresses.create': { + riskLevel: 'write', + description: 'Create a company address for campaign footers', + }, + 'addresses.update': { + riskLevel: 'write', + description: 'Update an existing company address', + }, + 'addresses.delete': { + riskLevel: 'destructive', + description: 'Delete a company address by its ID', + }, + 'calendars.list': { + riskLevel: 'read', + description: 'List calendar feeds configured on the account', + }, + 'calendars.get': { + riskLevel: 'read', + description: 'Retrieve a calendar feed by its ID', + }, + 'calendars.create': { + riskLevel: 'write', + description: 'Create a calendar feed for external calendar apps', + }, + 'calendars.update': { + riskLevel: 'write', + description: 'Update a calendar feed by its ID', + }, + 'calendars.delete': { + riskLevel: 'destructive', + description: 'Delete a calendar feed by its ID', + }, + 'eventTrackingEvents.list': { + riskLevel: 'read', + description: 'List the whitelisted event tracking event names', + }, + 'eventTrackingEvents.create': { + riskLevel: 'write', + description: 'Whitelist a new event name for tracking', + }, + 'eventTrackingEvents.delete': { + riskLevel: 'destructive', + description: 'Remove an event name from the tracking whitelist', + }, + 'tracking.getSiteStatus': { + riskLevel: 'read', + description: 'Check whether site tracking is enabled', + }, + 'tracking.getEventStatus': { + riskLevel: 'read', + description: 'Check whether event tracking is enabled', + }, + 'tracking.setSiteStatus': { + riskLevel: 'write', + description: 'Enable or disable site tracking for the account', + }, + 'tracking.setEventStatus': { + riskLevel: 'write', + description: 'Enable or disable event tracking for the account', + }, + 'tracking.trackEvent': { + riskLevel: 'write', + description: 'Record a custom event against a contact', + }, + 'tracking.listWhitelist': { + riskLevel: 'read', + description: 'List the domains allowed for site tracking', + }, + 'tracking.addWhitelist': { + riskLevel: 'write', + description: 'Add a domain to the site tracking whitelist', + }, + 'tracking.removeWhitelist': { + riskLevel: 'destructive', + description: 'Remove a domain from the site tracking whitelist', + }, + 'scores.list': { + riskLevel: 'read', + description: 'List the scoring rules configured on the account', + }, + 'emailActivities.list': { + riskLevel: 'read', + description: 'List email activity for a subscriber or deal', + }, + 'brandings.get': { + riskLevel: 'read', + description: 'Retrieve a branding configuration by its ID', + }, + 'brandings.update': { + riskLevel: 'write', + description: 'Update branding such as site name, logo and favicon', + }, + 'configs.update': { + riskLevel: 'write', + description: 'Update an account configuration value', + }, + 'products.search': { + riskLevel: 'read', + description: 'Search the e-commerce product catalog', + }, + 'products.get': { + riskLevel: 'read', + description: 'Retrieve a catalog product by its ID', + }, + 'products.create': { + riskLevel: 'write', + description: 'Create a product in the e-commerce catalog', + }, + 'products.update': { + riskLevel: 'write', + description: 'Update a catalog product', + }, + 'products.delete': { + riskLevel: 'destructive', + description: 'Delete a product from the e-commerce catalog', + }, + 'products.upsertBulk': { + riskLevel: 'write', + description: 'Create or update many catalog products in one request', + }, + 'orders.upsertBulk': { + riskLevel: 'write', + description: 'Create or update many orders synchronously', + }, + 'orders.upsertBulkAsync': { + riskLevel: 'write', + description: 'Create or update many orders asynchronously', + }, + 'recurringPayments.search': { + riskLevel: 'read', + description: 'Search recurring payment records by filter', + }, + 'recurringPayments.upsertBulk': { + riskLevel: 'write', + description: 'Create or update many recurring payments at once', + }, + 'browseSessions.search': { + riskLevel: 'read', + description: 'Search browse sessions for a contact and connection', + }, + 'browseSessions.save': { + riskLevel: 'write', + description: 'Create a browse session in a specified state', + }, + 'browseSessions.addToCart': { + riskLevel: 'write', + description: 'Flag a browse session as having items added to cart', + }, + 'smsBroadcasts.list': { + riskLevel: 'read', + description: 'List SMS broadcasts with optional name and status filters', + }, + 'smsBroadcasts.getMetrics': { + riskLevel: 'read', + description: 'Retrieve delivery metrics for specific SMS broadcasts', + }, + 'smsBroadcasts.getSnapshot': { + riskLevel: 'read', + description: 'Retrieve aggregate metrics across all SMS broadcasts', + }, + 'smsBroadcasts.createSnapshot': { + riskLevel: 'write', + description: 'Request a metrics snapshot for specific SMS broadcasts', + }, + 'smsBroadcasts.getFailures': { + riskLevel: 'read', + description: 'Group and count SMS delivery failures for a broadcast', + }, + 'smsBroadcasts.getRecipients': { + riskLevel: 'read', + description: 'List the contacts an SMS broadcast was sent to', + }, + 'smsCredits.get': { + riskLevel: 'read', + description: 'Retrieve SMS credit usage and remaining balance', + }, + 'tracking.getCode': { + riskLevel: 'read', + description: 'Retrieve the site tracking JavaScript snippet', + }, + 'smsBroadcastLists.list': { + riskLevel: 'read', + description: 'List the SMS broadcast lists available on the account', + }, + 'addressGroups.delete': { + riskLevel: 'destructive', + description: 'Delete an address group by its ID', + }, + 'ecomOrders.find': { + riskLevel: 'read', + description: 'Find one order by its store order ID within a connection', + }, + 'ecomOrders.upsert': { + riskLevel: 'write', + description: + 'Create an order, or update the one with the same store order ID. Concurrent upserts for the same connectionid and externalid can duplicate; serialize them or use orders.upsertBulk', + }, + 'ecomOrderProducts.listForOrder': { + riskLevel: 'read', + description: 'List the product lines belonging to one order', + }, + 'notes.createForAccount': { + riskLevel: 'write', + description: 'Add a note to a CRM account', + }, + 'notes.createForDeal': { + riskLevel: 'write', + description: 'Add a note to a deal', + }, + 'notes.updateForAccount': { + riskLevel: 'write', + description: 'Update a note attached to a CRM account', + }, + 'notes.updateForDeal': { + riskLevel: 'write', + description: 'Update a note attached to a deal', + }, + 'contactTasks.create': { + riskLevel: 'write', + description: 'Create a task against a contact', + }, + 'contactTasks.find': { + riskLevel: 'read', + description: 'Find contact tasks by title, optionally for one contact', + }, + 'segmentsV2.create': { + riskLevel: 'write', + description: 'Create an advanced segment with filtering conditions', + }, + 'segmentsV2.get': { + riskLevel: 'read', + description: 'Retrieve a V2 segment by its UUID', + }, + 'segmentsV2.update': { + riskLevel: 'write', + description: 'Update a V2 segment definition', + }, + 'segmentsV2.delete': { + riskLevel: 'destructive', + description: 'Delete a segment and every historic version of it', + }, + 'segmentsV2.getAtTimestamp': { + riskLevel: 'read', + description: 'Retrieve a segment as it stood at a point in time', + }, + 'segmentsV2.revertToTimestamp': { + riskLevel: 'write', + description: 'Revert a segment to how it looked at a point in time', + }, + 'segmentsV2.recentCounts': { + riskLevel: 'read', + description: 'Retrieve the most recent result count per segment', + }, + 'segmentsV2.countHistory': { + riskLevel: 'read', + description: 'List historic result counts for one segment', + }, + 'segmentsV2.countAtTimestamp': { + riskLevel: 'read', + description: 'Retrieve segment counts recorded before a timestamp', + }, + 'segmentsV2.match': { + riskLevel: 'read', + description: 'Check whether a contact matches a segment', + }, + 'segmentsV2.matchByExternalId': { + riskLevel: 'read', + description: 'Check segment membership using an external contact ID', + }, + 'segmentsV2.matchAll': { + riskLevel: 'write', + description: 'Start a match-all evaluation for every contact in a segment', + }, + 'segmentsV2.matchAllResult': { + riskLevel: 'read', + description: 'Fetch a match-all result set by its run ID', + }, + 'segmentsV2.matchSomeResult': { + riskLevel: 'read', + description: 'Fetch a partial segment match result set by run ID', + }, + 'taskReminders.create': { + riskLevel: 'write', + description: 'Create a reminder ahead of a deal task due date', + }, + 'customObjectSchemas.createChild': { + riskLevel: 'write', + description: 'Create a child schema under a public parent schema', + }, + 'imports.listAggregate': { + riskLevel: 'read', + description: 'Retrieve aggregate progress across all bulk import batches', + }, + 'browseSessions.testEvent': { + riskLevel: 'write', + description: 'Simulate a tracking event and return its debug output', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof activecampaignEndpointsNested +>; + +export type BaseActiveCampaignPlugin = + CorsairPlugin< + 'activecampaign', + typeof ActiveCampaignSchema, + typeof activecampaignEndpointsNested, + typeof activecampaignWebhooksNested, + T, + typeof defaultAuthType, + typeof activecampaignAuthConfig + >; + +export type InternalActiveCampaignPlugin = + BaseActiveCampaignPlugin; + +export type ExternalActiveCampaignPlugin< + T extends ActiveCampaignPluginOptions, +> = BaseActiveCampaignPlugin; + +export function activecampaign( + incomingOptions: ActiveCampaignPluginOptions & + T = {} as ActiveCampaignPluginOptions & T, +): ExternalActiveCampaignPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'activecampaign', + schema: ActiveCampaignSchema, + options: options, + hooks: options.hooks, + endpoints: activecampaignEndpointsNested, + webhooks: activecampaignWebhooksNested, + authConfig: activecampaignAuthConfig, + endpointMeta: activecampaignEndpointMeta, + endpointSchemas: activecampaignEndpointSchemas, + webhookSchemas: {}, + pluginWebhookMatcher: () => false, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: ActiveCampaignKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (!res) { + throw new AuthMissingError('activecampaign', 'api_key'); + } + return res; + } + throw new AuthMissingError('activecampaign', 'api_key'); + }, + }; +} + +export { activecampaignEndpointMeta }; +export type { ActiveCampaignEndpointInputs, ActiveCampaignEndpointOutputs }; diff --git a/packages/activecampaign/integration.test.ts b/packages/activecampaign/integration.test.ts new file mode 100644 index 000000000..a27f99599 --- /dev/null +++ b/packages/activecampaign/integration.test.ts @@ -0,0 +1,178 @@ +/** + * Live checks against a real ActiveCampaign account. + * + * Skipped unless both `ACTIVECAMPAIGN_API_KEY` and `ACTIVECAMPAIGN_ACCOUNT` + * are set, so CI and contributors without credentials are unaffected. Every + * operation here is read-only: nothing is created, changed or deleted. + */ +import { Accounts, Contacts, Deals, Lists, Platform, Tags } from './endpoints'; +import { ActiveCampaignEndpointOutputSchemas as Outputs } from './endpoints/types'; +import { activecampaign } from './index'; +import { + ActiveCampaignAccount, + ActiveCampaignContact, + ActiveCampaignDeal, + ActiveCampaignDealGroup, + ActiveCampaignDealStage, + ActiveCampaignList, + ActiveCampaignTag, + ActiveCampaignUser, +} from './schema/database'; + +const apiKey = process.env.ACTIVECAMPAIGN_API_KEY; +const account = process.env.ACTIVECAMPAIGN_ACCOUNT; + +const describeLive = apiKey && account ? describe : describe.skip; + +type Ctx = Parameters[0]; + +const upserts: { store: string; id: string; data: unknown }[] = []; + +function makeStore(name: string) { + return { + upsertByEntityId: async (id: string, data: unknown) => { + upserts.push({ store: name, id, data }); + }, + deleteByEntityId: async (_id: string) => true, + }; +} + +function makeCtx(): Ctx { + return { + key: apiKey ?? '', + options: { account }, + keys: { get_account: async () => account }, + db: { + contacts: makeStore('contacts'), + lists: makeStore('lists'), + tags: makeStore('tags'), + accounts: makeStore('accounts'), + deals: makeStore('deals'), + dealGroups: makeStore('dealGroups'), + dealStages: makeStore('dealStages'), + users: makeStore('users'), + }, + database: undefined, + $getAccountId: async () => 'integration-test', + } as unknown as Ctx; +} + +describeLive('ActiveCampaign live API', () => { + beforeEach(() => { + upserts.length = 0; + }); + + it('lists contacts matching the official/live schema', async () => { + const result = await Contacts.list(makeCtx(), { limit: 1 }); + expect(() => Outputs.contactsList.parse(result)).not.toThrow(); + expect(Array.isArray(result.contacts)).toBe(true); + if (result.contacts?.[0]) { + const row = result.contacts[0]; + expect(ActiveCampaignContact.parse(row).id).toBe(row.id); + expect(upserts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + store: 'contacts', + id: row.id, + data: expect.objectContaining({ id: row.id }), + }), + ]), + ); + } + }); + + it('lists mailing lists matching the live key set', async () => { + const result = await Lists.list(makeCtx(), { limit: 1 }); + expect(() => Outputs.listsList.parse(result)).not.toThrow(); + if (result.lists?.[0]) { + expect(ActiveCampaignList.parse(result.lists[0]).id).toBeTruthy(); + } + }); + + it('lists tags matching the live key set', async () => { + const result = await Tags.list(makeCtx(), { limit: 1 }); + expect(() => Outputs.tagsList.parse(result)).not.toThrow(); + if (result.tags?.[0]) { + expect(ActiveCampaignTag.parse(result.tags[0]).id).toBeTruthy(); + } + }); + + it('lists accounts matching the official/live schema', async () => { + const result = await Accounts.list(makeCtx(), { limit: 1 }); + expect(() => Outputs.accountsList.parse(result)).not.toThrow(); + if (result.accounts?.[0]) { + expect(ActiveCampaignAccount.parse(result.accounts[0]).id).toBeTruthy(); + } + }); + + it('lists deals matching the official list example types', async () => { + const result = await Deals.list(makeCtx(), { limit: 1 }); + expect(() => Outputs.dealsList.parse(result)).not.toThrow(); + if (result.deals?.[0]) { + const parsed = ActiveCampaignDeal.parse(result.deals[0]); + expect(parsed.id).toBeTruthy(); + } + }); + + it('lists pipelines and stages captured from a live create', async () => { + const groups = await Deals.listGroups(makeCtx(), { limit: 1 }); + expect(() => Outputs.dealGroupsList.parse(groups)).not.toThrow(); + if (groups.dealGroups?.[0]) { + expect( + ActiveCampaignDealGroup.parse(groups.dealGroups[0]).id, + ).toBeTruthy(); + } + const stages = await Deals.listStages(makeCtx(), { limit: 1 }); + expect(() => Outputs.dealStagesList.parse(stages)).not.toThrow(); + if (stages.dealStages?.[0]) { + expect( + ActiveCampaignDealStage.parse(stages.dealStages[0]).id, + ).toBeTruthy(); + } + }); + + it('lists users matching the live key set', async () => { + const result = await Platform.listUsers(makeCtx(), { limit: 1 }); + expect(() => Outputs.usersList.parse(result)).not.toThrow(); + if (result.users?.[0]) { + expect(ActiveCampaignUser.parse(result.users[0]).id).toBeTruthy(); + } + }); + + it('authenticates with Api-Token against the account subdomain', async () => { + const plugin = activecampaign({ key: apiKey, account }); + if (!plugin.keyBuilder) { + throw new Error('plugin keyBuilder is missing'); + } + const token = await plugin.keyBuilder( + { authType: 'api_key' } as never, + 'endpoint', + ); + expect(token).toBe(apiKey); + + const originalFetch = globalThis.fetch; + let captured: { url: string; token: string | null } | undefined; + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const headers = new Headers(init?.headers); + captured = { + url: String(input), + token: headers.get('Api-Token'), + }; + return originalFetch(input, init); + }) as typeof fetch; + try { + const result = await Contacts.list( + { ...makeCtx(), key: token ?? '' }, + { limit: 1 }, + ); + expect(result.meta).toBeDefined(); + expect(captured?.token).toBe(apiKey); + expect(captured?.url).toContain(`https://${account}.api-us1.com/`); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/activecampaign/jest.config.cjs b/packages/activecampaign/jest.config.cjs new file mode 100644 index 000000000..52f834cc6 --- /dev/null +++ b/packages/activecampaign/jest.config.cjs @@ -0,0 +1,63 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + target: 'ESNext', + types: ['node', 'jest'], + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + types: ['node', 'jest'], + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/db$': '/../corsair/db.ts', + '^corsair/orm$': '/../corsair/orm.ts', + '^corsair/http$': '/../corsair/http.ts', + '^corsair/setup$': '/../corsair/setup.ts', + '^corsair/tests$': '/../corsair/tests.ts', + '^corsair$': '/../corsair/index.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/activecampaign/package.json b/packages/activecampaign/package.json new file mode 100644 index 000000000..4fd224d8a --- /dev/null +++ b/packages/activecampaign/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/activecampaign", + "version": "0.1.0", + "description": "ActiveCampaign plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13", + "jest": "^29.7.0" + }, + "keywords": [ + "corsair", + "activecampaign", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/activecampaign/persist.test.ts b/packages/activecampaign/persist.test.ts new file mode 100644 index 000000000..55cfc9d1b --- /dev/null +++ b/packages/activecampaign/persist.test.ts @@ -0,0 +1,191 @@ +import { z } from 'zod'; +import { evictRow, persistRow, persistRows } from './endpoints/persist'; + +/** + * The caching rules, tested directly. + * + * These are the behaviours a reviewer cannot verify by reading the endpoint + * files, because the endpoints delegate to these helpers: an unrecognised row + * is skipped rather than stored, a skip is audible, a mirror failure never + * fails the call, and only an explicit delete evicts. + */ + +const Entity = z + .object({ id: z.string(), name: z.string().nullable().optional() }) + .loose(); + +interface FakeStore { + rows: Map>; + upsertByEntityId: jest.Mock; + deleteByEntityId: jest.Mock; +} + +function makeStore(options: { failWrites?: boolean } = {}): FakeStore { + const rows = new Map>(); + return { + rows, + upsertByEntityId: jest.fn( + async (id: string, data: Record) => { + if (options.failWrites) throw new Error('database unavailable'); + rows.set(id, data); + return data; + }, + ), + deleteByEntityId: jest.fn(async (id: string) => { + if (options.failWrites) throw new Error('database unavailable'); + rows.delete(id); + return true; + }), + }; +} + +describe('persistRow', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => warn.mockRestore()); + + it('writes a row that matches the entity schema', async () => { + const store = makeStore(); + await persistRow(store, Entity, { id: '7', name: 'kept' }, 'thing'); + expect(store.upsertByEntityId).toHaveBeenCalledTimes(1); + expect(store.rows.get('7')).toMatchObject({ id: '7', name: 'kept' }); + }); + + it('skips a row that does not match, and says so', async () => { + const store = makeStore(); + await persistRow(store, Entity, { name: 'no id here' }, 'thing'); + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('thing'); + }); + + it('skips a row whose id is not a usable string', async () => { + const Numeric = z.object({ id: z.union([z.string(), z.number()]) }).loose(); + const store = makeStore(); + await persistRow(store, Numeric, { id: 5 }, 'thing'); + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); + + /** + * A mirror is a convenience. Losing it must never fail the API call the + * caller actually made. + */ + it('does not throw when the store write fails', async () => { + const store = makeStore({ failWrites: true }); + await expect( + persistRow(store, Entity, { id: '7' }, 'thing'), + ).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); + + it('does nothing when there is no store for the entity', async () => { + await expect( + persistRow(undefined, Entity, { id: '7' }, 'thing'), + ).resolves.toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([[null], [undefined]])('ignores a %s row', async (row) => { + const store = makeStore(); + await persistRow(store, Entity, row, 'thing'); + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }); +}); + +describe('persistRows', () => { + let warn: jest.SpyInstance; + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => warn.mockRestore()); + + it('writes every valid row in a page', async () => { + const store = makeStore(); + const rows = Array.from({ length: 40 }, (_, i) => ({ id: String(i) })); + await persistRows(store, Entity, rows, 'thing'); + expect(store.upsertByEntityId).toHaveBeenCalledTimes(40); + expect(store.rows.size).toBe(40); + }); + + /** + * One bad row in a page must not cost the other rows - that would turn a + * schema gap into silent data loss across the whole page. + */ + it('keeps the good rows when one row is invalid', async () => { + const store = makeStore(); + await persistRows( + store, + Entity, + [{ id: '1' }, { name: 'broken' }, { id: '3' }], + 'thing', + ); + expect(store.rows.size).toBe(2); + expect([...store.rows.keys()].sort()).toEqual(['1', '3']); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it.each([[[]], [null], [undefined], ['not an array']])( + 'ignores a non-array page: %s', + async (rows) => { + const store = makeStore(); + await persistRows(store, Entity, rows, 'thing'); + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + }, + ); + + it('bounds concurrency rather than opening one write per row', async () => { + let inFlight = 0; + let peak = 0; + const store = { + upsertByEntityId: jest.fn(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 1)); + inFlight--; + }), + deleteByEntityId: jest.fn(), + }; + const rows = Array.from({ length: 100 }, (_, i) => ({ id: String(i) })); + await persistRows(store, Entity, rows, 'thing'); + expect(store.upsertByEntityId).toHaveBeenCalledTimes(100); + expect(peak).toBeLessThanOrEqual(16); + }); +}); + +describe('evictRow', () => { + let warn: jest.SpyInstance; + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => warn.mockRestore()); + + it('removes the row from the mirror', async () => { + const store = makeStore(); + await persistRow(store, Entity, { id: '7' }, 'thing'); + await evictRow(store, '7', 'thing'); + expect(store.deleteByEntityId).toHaveBeenCalledWith('7'); + expect(store.rows.has('7')).toBe(false); + }); + + it('does not throw when the eviction fails', async () => { + const store = makeStore({ failWrites: true }); + await expect(evictRow(store, '7', 'thing')).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); + + it('does nothing without a store or an id', async () => { + const store = makeStore(); + await evictRow(undefined, '7', 'thing'); + await evictRow(store, '', 'thing'); + expect(store.deleteByEntityId).not.toHaveBeenCalled(); + }); + + it('tolerates a store that cannot delete', async () => { + const store = { upsertByEntityId: jest.fn() }; + await expect(evictRow(store, '7', 'thing')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/activecampaign/routing.test.ts b/packages/activecampaign/routing.test.ts new file mode 100644 index 000000000..db81a16bc --- /dev/null +++ b/packages/activecampaign/routing.test.ts @@ -0,0 +1,393 @@ +import type { z } from 'zod'; +import { ActiveCampaignEndpointInputSchemas } from './endpoints/types'; +import { activecampaign, activecampaignEndpointMeta } from './index'; + +/** + * Exercises every registered operation against a mocked fetch and asserts the + * request it actually issues. + * + * This is the test that catches the bug class unit tests usually miss: a + * plausible but wrong URL. Three real routing errors were found this way + * against the live API - `siteTrackingWhitelist` (the route is + * `siteTracking/whitelist`), `dealOwners/bulkUpdate` (it is + * `deals/bulkUpdate/owners`), and `smsBroadcasts` (it is `sms/broadcasts`) - + * and none of them would fail a schema or registry test. + * + * Inputs are generated from each operation's own zod schema rather than + * hand-written 275 times, by parsing an empty object and filling whatever the + * schema complains about until it is satisfied. That keeps the test honest: it + * uses the same schema the runtime validates against. + */ + +const SCHEMAS = ActiveCampaignEndpointInputSchemas as unknown as Record< + string, + z.ZodType | undefined +>; + +/** Looks up an operation's input schema, failing loudly if it is missing. */ +function schemaFor(key: string): z.ZodType { + const schema = SCHEMAS[key]; + if (!schema) throw new Error(`No input schema registered for ${key}`); + return schema; +} + +const ACCOUNT = 'example'; +const TOKEN = 'test-token-value'; +const EXPECTED_BASE = `https://${ACCOUNT}.api-us1.com/api/3`; + +type AnyEndpoint = (ctx: unknown, input: unknown) => Promise; + +/** + * Enough of a response for every envelope the plugin reads. Composite + * operations look a contact up before routing, so an empty body would make + * them throw before issuing the request this test exists to check. + */ +const MOCK_BODY = { + contacts: [{ id: '1' }], + contactAutomations: [{ id: '1', automation: 'xx' }], + meta: { total: '1' }, +}; + +/** + * GraphQL is POST-only, including for queries, so a read there is still a + * POST. Event tracking posts to a separate host entirely. + */ +const GRAPHQL_PATH = '/ecom/graphql'; +const TRACKING_HOST = 'https://trackcmp.net/'; + +/** + * Builds a minimal valid input by walking the schema itself. + * + * Driving this from the schema rather than a hand-written fixture per + * operation means the 275 routing cases below use exactly the shape the + * runtime validates, and a schema change cannot leave a stale fixture behind. + */ +function sampleInput(schema: z.ZodType): Record { + const minimal = (sampleFor(schema) ?? {}) as Record; + if (schema.safeParse(minimal).success) return minimal; + + // A cross-field refinement can make the minimal object invalid - the + // stage-delete schema requires the relocation targets when action_type is + // 'Move'. Filling the optional keys as well satisfies those. + const def = defOf(schema); + const full: Record = { ...minimal }; + for (const [key, child] of Object.entries(def?.shape ?? {})) { + if (full[key] !== undefined) continue; + const v = sampleFor(child); + if (v !== undefined) full[key] = v; + } + return full; +} + +interface ZodDef { + type: string; + shape?: Record; + element?: z.ZodType; + innerType?: z.ZodType; + entries?: Record; + options?: z.ZodType[]; + values?: unknown[]; +} + +function defOf(schema: z.ZodType): ZodDef { + return (schema as unknown as { def: ZodDef }).def; +} + +/** Candidate strings, in order, so a format-checked string still validates. */ +const STRING_CANDIDATES = [ + 'xx', + 'someone@example.com', + 'https://example.com', + '2026-08-14T00:00:00Z', +]; + +function sampleFor(schema: z.ZodType): unknown { + const def = defOf(schema); + if (!def) return undefined; + + switch (def.type) { + case 'optional': + case 'nullable': + case 'default': + case 'catch': + return def.innerType ? sampleFor(def.innerType) : undefined; + + case 'object': { + const out: Record = {}; + for (const [key, child] of Object.entries(def.shape ?? {})) { + // Omitting optional keys keeps the sample minimal, which is what + // makes an accidental required-field regression visible. + if (child.safeParse(undefined).success) continue; + const v = sampleFor(child); + if (v !== undefined) out[key] = v; + } + return out; + } + + case 'array': + return def.element ? [sampleFor(def.element)] : []; + + case 'enum': + return Object.values(def.entries ?? {})[0]; + + case 'literal': + return def.values?.[0]; + + case 'union': { + const first = def.options?.[0]; + return first ? sampleFor(first) : undefined; + } + + case 'record': + return {}; + + case 'number': + case 'int': + return 1; + + case 'boolean': + return true; + + case 'string': { + for (const candidate of STRING_CANDIDATES) { + if (schema.safeParse(candidate).success) return candidate; + } + return 'xx'; + } + + default: + return {}; + } +} + +interface Captured { + url: string; + method: string; + headers: Record; + body?: string; +} + +function readHeaders(init?: RequestInit): Record { + const raw = init?.headers; + if (!raw) return {}; + if (raw instanceof Headers) return Object.fromEntries(raw.entries()); + if (Array.isArray(raw)) return Object.fromEntries(raw); + return { ...(raw as Record) }; +} + +describe('operation routing', () => { + const plugin = activecampaign({ key: TOKEN, account: ACCOUNT }); + const tree = plugin.endpoints as Record>; + const META = activecampaignEndpointMeta as Record< + string, + { riskLevel: string } + >; + + const originalFetch = globalThis.fetch; + let calls: Captured[] = []; + let warn: jest.SpyInstance; + + beforeEach(() => { + calls = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method ?? 'GET', + headers: readHeaders(init), + body: typeof init?.body === 'string' ? init.body : undefined, + }); + // Every envelope key the plugin reads is absent, which exercises the + // "no rows" path rather than the happy path. + return new Response(JSON.stringify(MOCK_BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + // The event logger has no database in this harness and warns; that is + // expected and must not drown the output. + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + warn.mockRestore(); + }); + + function makeCtx() { + return { + key: TOKEN, + options: { account: ACCOUNT }, + keys: { get_account: async () => ACCOUNT }, + db: {}, + $getAccountId: async () => 'test-account', + database: undefined, + }; + } + + /** Every registered operation, flattened to (path, handler). */ + const OPERATIONS: Array<[string, AnyEndpoint]> = Object.entries(tree).flatMap( + ([group, leaves]) => + Object.entries(leaves).map( + ([leaf, fn]) => [`${group}.${leaf}`, fn] as [string, AnyEndpoint], + ), + ); + + it('exposes every operation in the registry', () => { + expect(OPERATIONS).toHaveLength(304); + expect(OPERATIONS).toHaveLength(Object.keys(META).length); + }); + + it('generates a valid input for every operation schema', () => { + const schemas = SCHEMAS; + const unbuildable: string[] = []; + for (const [path] of OPERATIONS) { + const key = path.replace(/\.(.)/g, (_m, c: string) => c.toUpperCase()); + const schema = schemas[key]; + expect(schema).toBeDefined(); + if (schema && !schema.safeParse(sampleInput(schema)).success) { + unbuildable.push(path); + } + } + // A schema this cannot satisfy would silently skip the routing checks + // below, so the list has to be empty rather than merely short. + expect(unbuildable).toEqual([]); + }); + + describe.each(OPERATIONS)('%s', (path, handler) => { + const key = path.replace(/\.(.)/g, (_m, c: string) => c.toUpperCase()); + const schema = schemaFor(key); + + it('issues a request to the account base URL with the token in a header', async () => { + await handler(makeCtx(), sampleInput(schema)); + + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + // Event tracking deliberately posts to a separate host. + if (call.url.startsWith(TRACKING_HOST)) continue; + + expect(call.url.startsWith(EXPECTED_BASE)).toBe(true); + + const headerNames = Object.keys(call.headers).map((h) => + h.toLowerCase(), + ); + expect(headerNames).toContain('api-token'); + + // A credential in the query string would leak into logs and + // referrer headers. + expect(call.url).not.toContain(TOKEN); + } + }); + + it('uses a method consistent with its declared risk level', async () => { + await handler(makeCtx(), sampleInput(schema)); + const external = calls.filter((c) => !c.url.startsWith(TRACKING_HOST)); + if (external.length === 0) { + // Event tracking posts only to the separate tracking host. + expect(calls.some((c) => c.url.startsWith(TRACKING_HOST))).toBe(true); + return; + } + + const rest = external.filter((c) => !c.url.includes(GRAPHQL_PATH)); + const methods = rest.map((c) => c.method.toUpperCase()); + if (rest.length === 0) { + // A GraphQL-only operation: POST is correct even for a query. + expect(external.length).toBeGreaterThan(0); + expect(external.every((c) => c.method.toUpperCase() === 'POST')).toBe( + true, + ); + return; + } + if (META[path]?.riskLevel === 'read') { + // A REST read must never issue a state-changing request. + expect(methods.every((m) => m === 'GET')).toBe(true); + } else { + expect(methods.some((m) => m !== 'GET')).toBe(true); + } + }); + + it('never interpolates undefined into the path', async () => { + await handler(makeCtx(), sampleInput(schema)); + for (const call of calls) { + expect(call.url).not.toContain('/undefined'); + expect(call.url).not.toContain('undefined?'); + expect(call.url).not.toMatch(/=undefined(&|$)/); + } + }); + }); +}); + +describe('destructive operations', () => { + const plugin = activecampaign({ key: TOKEN, account: ACCOUNT }); + const tree = plugin.endpoints as Record>; + const META = activecampaignEndpointMeta as Record< + string, + { riskLevel: string } + >; + + const originalFetch = globalThis.fetch; + let calls: Captured[] = []; + let warn: jest.SpyInstance; + + beforeEach(() => { + calls = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method ?? 'GET', + headers: readHeaders(init), + }); + return new Response(JSON.stringify(MOCK_BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + warn.mockRestore(); + }); + + const DESTRUCTIVE = Object.entries(tree) + .flatMap(([group, leaves]) => + Object.entries(leaves).map( + ([leaf, fn]) => [`${group}.${leaf}`, fn] as [string, AnyEndpoint], + ), + ) + .filter(([path]) => META[path]?.riskLevel === 'destructive'); + + it('has destructive operations to check', () => { + // Without this the loop below would pass by matching nothing. + expect(DESTRUCTIVE).toHaveLength(46); + }); + + it.each(DESTRUCTIVE)( + '%s issues a DELETE or an explicit bulk write', + async (path, handler) => { + const key = path.replace(/\.(.)/g, (_m, c: string) => c.toUpperCase()); + const schema = schemaFor(key); + + await handler( + { + key: TOKEN, + options: { account: ACCOUNT }, + keys: { get_account: async () => ACCOUNT }, + db: {}, + $getAccountId: async () => 'test-account', + database: undefined, + }, + sampleInput(schema), + ); + + const methods = calls.map((c) => c.method.toUpperCase()); + // Most deletes are a DELETE; the bulk ones POST an id list, and the + // stage-delete-with-move issues a PUT before its DELETE. + expect( + methods.includes('DELETE') || + methods.includes('POST') || + methods.includes('PATCH'), + ).toBe(true); + }, + ); +}); diff --git a/packages/activecampaign/schema.test.ts b/packages/activecampaign/schema.test.ts new file mode 100644 index 000000000..086b1f344 --- /dev/null +++ b/packages/activecampaign/schema.test.ts @@ -0,0 +1,761 @@ +import type { z } from 'zod'; +import { ActiveCampaignSchema } from './schema'; +import { + ActiveCampaignAccount, + ActiveCampaignAddress, + ActiveCampaignBranding, + ActiveCampaignCalendar, + ActiveCampaignConnection, + ActiveCampaignContact, + ActiveCampaignContactList, + ActiveCampaignContactTag, + ActiveCampaignDeal, + ActiveCampaignDealCustomFieldMeta, + ActiveCampaignDealGroup, + ActiveCampaignDealRole, + ActiveCampaignDealStage, + ActiveCampaignDealTaskType, + ActiveCampaignField, + ActiveCampaignFieldOption, + ActiveCampaignFieldRel, + ActiveCampaignFieldValue, + ActiveCampaignGroupLimit, + ActiveCampaignGroupMember, + ActiveCampaignList, + ActiveCampaignMessage, + ActiveCampaignNote, + ActiveCampaignScore, + ActiveCampaignSegment, + ActiveCampaignTag, + ActiveCampaignTaskOutcome, + ActiveCampaignUser, + ActiveCampaignWebhook, +} from './schema/database'; + +/** + * Keys captured from a live ActiveCampaign account on 2026-08-14. + * + * These are the exact key sets the API returned, not the documented ones. If + * ActiveCampaign adds a field, this test fails and the schema is updated - + * which is the point: it stops the schema drifting away from what the API + * actually sends. + */ +const CAPTURED_KEYS = { + contact: [ + 'accountContacts', + 'adate', + 'anonymized', + 'best_send_hour', + 'bounced_date', + 'bounced_hard', + 'bounced_soft', + 'cdate', + 'created_by', + 'created_timestamp', + 'created_utc_timestamp', + 'deleted', + 'deleted_at', + 'edate', + 'email', + 'email_domain', + 'email_local', + 'firstName', + 'gravatar', + 'hash', + 'id', + 'ip', + 'lastName', + 'last_click_date', + 'last_mpp_open_date', + 'last_open_date', + 'links', + 'mpp_tracking', + 'organization', + 'orgid', + 'orgname', + 'phone', + 'rating_tstamp', + 'scoreValues', + 'segmentio_id', + 'sentcnt', + 'sms_consent', + 'sms_consent_updated_at', + 'socialdata_lastcheck', + 'ua', + 'udate', + 'updated_by', + 'updated_timestamp', + 'updated_utc_timestamp', + 'whatsapp_id', + 'whatsapp_username', + ], + tag: [ + 'cdate', + 'created_by', + 'created_timestamp', + 'deleted', + 'description', + 'id', + 'links', + 'subscriber_count', + 'tag', + 'tagType', + 'updated_by', + 'updated_timestamp', + ], + contactTag: [ + 'cdate', + 'contact', + 'created_by', + 'created_timestamp', + 'id', + 'links', + 'tag', + 'updated_by', + 'updated_timestamp', + ], + fieldValue: [ + 'cdate', + 'contact', + 'created_by', + 'field', + 'id', + 'links', + 'owner', + 'udate', + 'updated_by', + 'value', + ], + fieldOption: [ + 'cdate', + 'field', + 'id', + 'isdefault', + 'label', + 'links', + 'orderid', + 'udate', + 'value', + ], + fieldRel: ['cdate', 'dorder', 'field', 'id', 'links', 'relid'], + groupMember: ['group_id', 'id', 'links', 'ordernum', 'rel_id'], + dealTaskType: [ + 'cdate', + 'created_by', + 'created_utc_timestamp', + 'defduration', + 'display_order', + 'id', + 'links', + 'outcomes', + 'status', + 'title', + 'udate', + 'updated_by', + 'updated_utc_timestamp', + ], + dealRole: ['created_timestamp', 'id', 'links', 'title', 'updated_timestamp'], + dealCustomFieldMeta: [ + 'createdBy', + 'createdTimestamp', + 'displayOrder', + 'fieldDefault', + 'fieldDefaultCurrency', + 'fieldLabel', + 'fieldOptions', + 'fieldType', + 'hideFieldFlag', + 'id', + 'isFormVisible', + 'isRequired', + 'knownFieldId', + 'links', + 'personalization', + 'updatedBy', + 'updatedTimestamp', + ], + message: [ + 'cdate', + 'charset', + 'ed_instanceid', + 'ed_version', + 'encoding', + 'format', + 'fromemail', + 'fromname', + 'has_predictive_content', + 'hidden', + 'html', + 'htmlfetch', + 'id', + 'language_code', + 'links', + 'mdate', + 'name', + 'preheader_text', + 'preview_data', + 'preview_mime', + 'priority', + 'reply2', + 'source', + 'subject', + 'text', + 'textfetch', + 'user', + 'userid', + ], + segment: [ + 'created_by', + 'created_timestamp', + 'hidden', + 'id', + 'links', + 'logic', + 'name', + 'segmentid_v2', + 'seriesid', + 'updated_by', + 'updated_timestamp', + ], + user: [ + 'email', + 'firstName', + 'id', + 'lang', + 'lastName', + 'links', + 'localZoneid', + 'mfaEnabled', + 'phone', + 'roles', + 'signature', + 'username', + ], + list: [ + 'active_subscribers', + 'analytics_domains', + 'analytics_source', + 'analytics_ua', + 'carboncopy', + 'cdate', + 'channel', + 'created_by', + 'created_timestamp', + 'deletestamp', + 'description', + 'facebook_session', + 'fulladdress', + 'get_unsubscribe_reason', + 'id', + 'links', + 'name', + 'non_deleted_subscribers', + 'optinmessageid', + 'optinoptout', + 'optoutconf', + 'p_embed_image', + 'p_use_analytics_link', + 'p_use_analytics_read', + 'p_use_captcha', + 'p_use_facebook', + 'p_use_tracking', + 'p_use_twitter', + 'private', + 'require_name', + 'send_last_broadcast', + 'sender_addr1', + 'sender_addr2', + 'sender_city', + 'sender_country', + 'sender_name', + 'sender_phone', + 'sender_reminder', + 'sender_state', + 'sender_url', + 'sender_zip', + 'stringid', + 'subscription_notify', + 'to_name', + 'twitter_token', + 'twitter_token_secret', + 'udate', + 'unsubscription_notify', + 'updated_by', + 'updated_timestamp', + 'user', + 'userid', + ], + field: [ + 'cdate', + 'cols', + 'created_by', + 'created_timestamp', + 'defval', + 'descript', + 'id', + 'isrequired', + 'links', + 'options', + 'ordernum', + 'perstag', + 'relations', + 'rows', + 'service', + 'show_in_list', + 'title', + 'type', + 'udate', + 'updated_by', + 'updated_timestamp', + 'visible', + ], + contactList: [ + 'automation', + 'autosyncLog', + 'campaign', + 'channel', + 'contact', + 'created_by', + 'created_timestamp', + 'first_name', + 'form', + 'id', + 'ip4Sub', + 'ip4Unsub', + 'ip4_last', + 'last_name', + 'links', + 'list', + 'message', + 'responder', + 'sdate', + 'seriesid', + 'sourceid', + 'status', + 'sync', + 'udate', + 'unsubreason', + 'unsubscribeAutomation', + 'updated_by', + 'updated_timestamp', + ], + account: [ + 'accountUrl', + 'contactCount', + 'createdTimestamp', + 'dealCount', + 'id', + 'links', + 'name', + 'owner', + 'updatedTimestamp', + ], + deal: [ + 'account', + 'activitycount', + 'cdate', + 'contact', + 'currency', + 'customerAccount', + 'description', + 'edate', + 'group', + 'hash', + 'id', + 'isDisabled', + 'links', + 'mdate', + 'nextdate', + 'nextdealid', + 'nexttaskid', + 'organization', + 'owner', + 'percent', + 'stage', + 'status', + 'title', + 'value', + 'winProbability', + 'winProbabilityMdate', + ], + dealGroup: [ + 'allgroups', + 'allusers', + 'autoassign', + 'cdate', + 'count', + 'currency', + 'id', + 'links', + 'source', + 'stages', + 'title', + 'udate', + 'win_probability_initialize_date', + ], + dealStage: [ + 'cardRegion1', + 'cardRegion2', + 'cardRegion3', + 'cardRegion4', + 'cardRegion5', + 'cdate', + 'color', + 'dealOrder', + 'group', + 'id', + 'links', + 'order', + 'title', + 'udate', + 'width', + ], + address: [ + 'address1', + 'address2', + 'allgroup', + 'city', + 'companyName', + 'country', + 'created_by', + 'created_timestamp', + 'district', + 'id', + 'isDefault', + 'links', + 'smsName', + 'state', + 'updated_by', + 'updated_timestamp', + 'zip', + ], + calendar: [ + 'cdate', + 'id', + 'links', + 'mdate', + 'notification', + 'title', + 'token', + 'type', + 'userid', + ], + webhook: [ + 'cdate', + 'deactivated_date', + 'events', + 'id', + 'links', + 'listid', + 'name', + 'sources', + 'state', + 'url', + ], + note: [ + 'cdate', + 'id', + 'is_draft', + 'links', + 'mdate', + 'note', + 'owner', + 'relid', + 'reltype', + 'user', + 'userid', + ], + connection: [ + 'cdate', + 'connectionType', + 'credentialExpiration', + 'disconnectDate', + 'externalid', + 'id', + 'isInternal', + 'lastSync', + 'linkUrl', + 'links', + 'listId', + 'logoUrl', + 'name', + 'planTier', + 'service', + 'serviceName', + 'status', + 'syncStatus', + 'sync_request_time', + 'sync_start_time', + 'udate', + ], + taskOutcome: [ + 'created_by', + 'created_utc_timestamp', + 'dealTasktype_ids', + 'disabled', + 'id', + 'links', + 'sentiment', + 'title', + 'updated_by', + 'updated_utc_timestamp', + ], + groupLimit: [ + 'abuseRatio', + 'forceSenderInfo', + 'group', + 'groupid', + 'id', + 'limitAttachment', + 'limitCampaign', + 'limitCampaignType', + 'limitContact', + 'limitList', + 'limitMail', + 'limitMailType', + 'limitUser', + 'links', + ], + score: [ + 'cdate', + 'descript', + 'id', + 'links', + 'mdate', + 'name', + 'reltype', + 'status', + ], + branding: [ + 'adminTemplateCss', + 'adminTemplateHtm', + 'admin_template_css_backup', + 'admin_template_htm_backup', + 'copyright', + 'created_timestamp', + 'favicon', + 'footerHtmlValue', + 'footerTextValue', + 'groupid', + 'headerHtmlValue', + 'headerTextValue', + 'help', + 'id', + 'license', + 'links', + 'publicTemplateCss', + 'publicTemplateHtm', + 'siteLogo', + 'siteLogoSmall', + 'siteName', + 'updated_timestamp', + 'version', + 'zendeskWidgetEnabled', + ], +} as const; + +const ENTITY_COUNT = 43; + +function declaredKeys(schema: { + shape?: Record; + def?: { shape?: Record }; +}): string[] { + const shape = schema.shape ?? schema.def?.shape ?? {}; + return Object.keys(shape).sort(); +} + +/** + * Derived from the registry rather than hand-listed, so an entity cannot be + * added without these tests covering it. + */ +const REGISTERED = Object.entries(ActiveCampaignSchema.entities) as Array< + [string, z.ZodType] +>; + +describe('ActiveCampaign entity registry', () => { + it('registers every entity exactly once', () => { + expect(REGISTERED).toHaveLength(ENTITY_COUNT); + const names = REGISTERED.map(([n]) => n); + expect(new Set(names).size).toBe(ENTITY_COUNT); + }); + + it('mirrors no transactional resource', () => { + const names = REGISTERED.map(([n]) => n); + // Appended continuously and only meaningful against a date range. + for (const banned of [ + 'dealActivities', + 'emailActivities', + 'contactAutomations', + 'ecomOrders', + 'ecomOrderProducts', + 'ecomOrderActivities', + 'activities', + 'configs', + ]) { + expect(names).not.toContain(banned); + } + }); +}); + +describe('ActiveCampaign entity schemas', () => { + it.each([ + ['contact', ActiveCampaignContact, CAPTURED_KEYS.contact], + ['tag', ActiveCampaignTag, CAPTURED_KEYS.tag], + ['contactTag', ActiveCampaignContactTag, CAPTURED_KEYS.contactTag], + ['fieldValue', ActiveCampaignFieldValue, CAPTURED_KEYS.fieldValue], + ['fieldOption', ActiveCampaignFieldOption, CAPTURED_KEYS.fieldOption], + ['fieldRel', ActiveCampaignFieldRel, CAPTURED_KEYS.fieldRel], + ['groupMember', ActiveCampaignGroupMember, CAPTURED_KEYS.groupMember], + ['dealTaskType', ActiveCampaignDealTaskType, CAPTURED_KEYS.dealTaskType], + ['dealRole', ActiveCampaignDealRole, CAPTURED_KEYS.dealRole], + [ + 'dealCustomFieldMeta', + ActiveCampaignDealCustomFieldMeta, + CAPTURED_KEYS.dealCustomFieldMeta, + ], + ['message', ActiveCampaignMessage, CAPTURED_KEYS.message], + ['segment', ActiveCampaignSegment, CAPTURED_KEYS.segment], + ['user', ActiveCampaignUser, CAPTURED_KEYS.user], + ['list', ActiveCampaignList, CAPTURED_KEYS.list], + ['field', ActiveCampaignField, CAPTURED_KEYS.field], + ['contactList', ActiveCampaignContactList, CAPTURED_KEYS.contactList], + ['account', ActiveCampaignAccount, CAPTURED_KEYS.account], + ['deal', ActiveCampaignDeal, CAPTURED_KEYS.deal], + ['dealGroup', ActiveCampaignDealGroup, CAPTURED_KEYS.dealGroup], + ['dealStage', ActiveCampaignDealStage, CAPTURED_KEYS.dealStage], + ['address', ActiveCampaignAddress, CAPTURED_KEYS.address], + ['calendar', ActiveCampaignCalendar, CAPTURED_KEYS.calendar], + ['webhook', ActiveCampaignWebhook, CAPTURED_KEYS.webhook], + ['note', ActiveCampaignNote, CAPTURED_KEYS.note], + ['connection', ActiveCampaignConnection, CAPTURED_KEYS.connection], + ['taskOutcome', ActiveCampaignTaskOutcome, CAPTURED_KEYS.taskOutcome], + ['groupLimit', ActiveCampaignGroupLimit, CAPTURED_KEYS.groupLimit], + ['score', ActiveCampaignScore, CAPTURED_KEYS.score], + ['branding', ActiveCampaignBranding, CAPTURED_KEYS.branding], + ])('declares every captured key of %s', (_name, schema, captured) => { + expect(captured.length).toBeGreaterThan(0); + const declared = declaredKeys(schema as never); + for (const key of captured) { + expect(declared).toContain(key); + } + }); + + /** + * Only the primary key is required. ActiveCampaign omits or nulls fields + * depending on plan and permissions, and a rejected row is a lost row. + */ + it.each(REGISTERED)( + 'parses a %s row carrying only an id', + (_name, schema) => { + expect(schema.safeParse({ id: '1' }).success).toBe(true); + }, + ); + + it.each(REGISTERED)('rejects a %s row with no id', (_name, schema) => { + expect(schema.safeParse({}).success).toBe(false); + }); + + it.each(REGISTERED)( + 'preserves unknown fields on %s rather than stripping them', + (_name, schema) => { + const parsed = schema.parse({ + id: '1', + a_field_added_next_year: 'kept', + }) as Record; + expect(parsed.a_field_added_next_year).toBe('kept'); + }, + ); + + /** + * The camelCase resources return real JSON numbers where the older + * snake_case ones stringify everything. A string-only schema would reject + * every row those endpoints send. + */ + it('accepts the numeric ids groupMembers actually returns', () => { + const parsed = ActiveCampaignGroupMember.parse({ + id: 13, + rel_id: 4, + group_id: 1, + ordernum: 2, + }); + expect(parsed.id).toBe('13'); + expect(parsed.rel_id).toBe(4); + }); + + it('accepts string ids on groupMembers too', () => { + expect(ActiveCampaignGroupMember.parse({ id: '13' }).id).toBe('13'); + }); + + it('accepts the numeric flags dealCustomFieldMeta returns', () => { + const parsed = ActiveCampaignDealCustomFieldMeta.parse({ + id: 7, + isRequired: 1, + isFormVisible: 0, + displayOrder: 3, + }); + expect(parsed.id).toBe('7'); + expect(parsed.isRequired).toBe(1); + }); + + it('models the snake_case resources as strings', () => { + const parsed = ActiveCampaignTag.parse({ + id: '1', + tag: 'corsair-recon', + subscriber_count: '0', + deleted: '0', + }); + expect(parsed.id).toBe('1'); + expect(parsed.subscriber_count).toBe('0'); + }); + + it('accepts nulls in every non-key field', () => { + const result = ActiveCampaignContact.safeParse({ + id: '1', + email: null, + firstName: null, + lastName: null, + phone: null, + }); + expect(result.success).toBe(true); + }); + + /** + * POST /deals returns value and status as JSON numbers; GET returns + * strings. A string-only schema would skip every create response. + */ + it('accepts the numeric deal create payload and the string list payload', () => { + expect( + ActiveCampaignDeal.safeParse({ + id: '1', + value: 10000, + status: 0, + isDisabled: false, + }).success, + ).toBe(true); + expect( + ActiveCampaignDeal.safeParse({ + id: '1', + value: '10000', + status: '0', + isDisabled: 1, + }).success, + ).toBe(true); + }); + + it('accepts integer isDefault on address create', () => { + expect( + ActiveCampaignAddress.safeParse({ + id: '1', + companyName: 'Corsair Verify Co', + isDefault: 1, + }).success, + ).toBe(true); + expect( + ActiveCampaignAddress.safeParse({ + id: '1', + companyName: 'Corsair Verify Co', + isDefault: '1', + }).success, + ).toBe(true); + }); +}); diff --git a/packages/activecampaign/schema/database.ts b/packages/activecampaign/schema/database.ts new file mode 100644 index 000000000..67504c0a6 --- /dev/null +++ b/packages/activecampaign/schema/database.ts @@ -0,0 +1,1132 @@ +import { z } from 'zod'; + +/** + * Locally persisted ActiveCampaign entities. + * + * Field names match official JSON keys. + * Docs: https://developers.activecampaign.com/reference/overview + * + * Each field is labeled from the official attribute table / example payload, + * or as live-observed when this account (2026-08-14) returned a key the docs + * example omits. Only the primary key is required: ActiveCampaign omits or + * nulls fields depending on plan, permissions and enabled features. + * + * Almost every scalar is a JSON string, including ids (`"1"`), counts (`"0"`) + * and flags (`"0"` / `"1"`). Newer camelCase resources (`groupMembers`, + * `dealCustomFieldMeta`, `accountCustomFieldMeta`) return real JSON numbers. + * Create vs list also disagrees on a few CRM fields (`deal.value` is an + * integer on POST and a string on GET) — those are unions so a create + * response is not skipped by the mirror. + * + * `schema.test.ts` asserts every captured key is declared here. + */ + +/** Nullable-optional string — the shape of nearly every ActiveCampaign field. */ +const S = z.string().nullable().optional(); +/** + * Observed as a JSON number on at least one endpoint (or as a string on + * another). Accepts either so one representation cannot reject the other. + */ +const SN = z.union([z.string(), z.number()]).nullable().optional(); +/** Real JSON boolean, or the `"0"`/`"1"`/`1` forms ActiveCampaign also sends. */ +const Flag = z + .union([z.boolean(), z.string(), z.number()]) + .nullable() + .optional(); +/** Sideloaded relation arrays and the `links` object are shape-unstable. */ +const Unknown = z.unknown().nullable().optional(); + +/** + * A contact. Official: + * https://developers.activecampaign.com/reference/list-all-contacts + * Live 2026-08-14: 46 keys. Docs example omits orgname, anonymized, deleted_at, + * created/updated timestamps, mpp_tracking, last_*_date, sms_consent and + * whatsapp_* — those are live-observed. + */ +export const ActiveCampaignContact = z + .object({ + /** Unique id of the contact. */ + id: z.string(), + /** Email address of the contact. */ + email: S, + /** First name. */ + firstName: S, + /** Last name. */ + lastName: S, + /** Phone number. */ + phone: S, + /** Organization id (deprecated; use account-contact). Official example. */ + orgid: S, + /** Organization name. Live-observed 2026-08-14. */ + orgname: S, + /** Organization id when set, otherwise null. Official example. */ + organization: S, + /** Creation date. */ + cdate: S, + /** Last update date. */ + udate: S, + /** Last activity date. */ + adate: S, + /** Last email date. */ + edate: S, + /** Contact hash. */ + hash: S, + /** Last known IP. */ + ip: S, + /** Last known user agent. */ + ua: S, + /** Gravatar flag (`"0"` / `"1"` / `"3"`). */ + gravatar: S, + /** Soft-deleted flag. */ + deleted: S, + /** Deletion timestamp. Live-observed 2026-08-14. */ + deleted_at: S, + /** Anonymized flag. Live-observed 2026-08-14. */ + anonymized: S, + /** Local part of the email. */ + email_local: S, + /** Domain part of the email. */ + email_domain: S, + /** Segment.io identifier. */ + segmentio_id: S, + /** Hard bounce count. */ + bounced_hard: S, + /** Soft bounce count. */ + bounced_soft: S, + /** Last bounce date. */ + bounced_date: S, + /** Emails sent to this contact. */ + sentcnt: S, + /** Lead-scoring timestamp. */ + rating_tstamp: S, + /** Last social-data enrichment check. */ + socialdata_lastcheck: S, + /** Creating user id. Live-observed 2026-08-14. */ + created_by: S, + /** Updating user id. Live-observed 2026-08-14. */ + updated_by: S, + created_utc_timestamp: S, + updated_utc_timestamp: S, + created_timestamp: S, + updated_timestamp: S, + /** Machine-open tracking flag. Live-observed 2026-08-14. */ + mpp_tracking: S, + last_click_date: S, + last_open_date: S, + last_mpp_open_date: S, + best_send_hour: S, + sms_consent: S, + sms_consent_updated_at: S, + whatsapp_id: S, + whatsapp_username: S, + /** Sideloaded score values. */ + scoreValues: Unknown, + /** Sideloaded account-contact ids. */ + accountContacts: Unknown, + links: Unknown, + }) + .loose(); + +/** + * A mailing list. Official: + * https://developers.activecampaign.com/reference/create-new-list + * Live 2026-08-14: 52 keys. + */ +export const ActiveCampaignList = z + .object({ + /** Unique id of the list. */ + id: z.string(), + /** List name. */ + name: S, + /** URL-safe list identifier. */ + stringid: S, + /** Owning user id. */ + userid: S, + /** Owning user id (duplicate of userid on some payloads). */ + user: S, + description: S, + cdate: S, + udate: S, + channel: S, + private: S, + deletestamp: S, + active_subscribers: S, + non_deleted_subscribers: S, + subscription_notify: S, + unsubscription_notify: S, + require_name: S, + get_unsubscribe_reason: S, + to_name: S, + optinoptout: S, + optinmessageid: S, + optoutconf: S, + carboncopy: S, + fulladdress: S, + sender_name: S, + sender_addr1: S, + sender_addr2: S, + sender_city: S, + sender_state: S, + sender_zip: S, + sender_country: S, + sender_phone: S, + sender_url: S, + sender_reminder: S, + /** Official default is true; this plugin sends false on create. */ + send_last_broadcast: S, + analytics_domains: S, + analytics_source: S, + analytics_ua: S, + twitter_token: S, + twitter_token_secret: S, + facebook_session: S, + p_use_tracking: S, + p_use_analytics_read: S, + p_use_analytics_link: S, + p_use_twitter: S, + p_use_facebook: S, + p_embed_image: S, + p_use_captcha: S, + created_by: S, + updated_by: S, + created_timestamp: S, + updated_timestamp: S, + links: Unknown, + }) + .loose(); + +/** + * A tag. Reference data - tags are a controlled vocabulary applied to + * contacts. + */ +export const ActiveCampaignTag = z + .object({ + id: z.string(), + tag: S, + tagType: S, + description: S, + subscriber_count: S, + deleted: S, + cdate: S, + created_by: S, + updated_by: S, + created_timestamp: S, + updated_timestamp: S, + links: Unknown, + }) + .loose(); + +/** + * A custom field *definition* (not a value). Reference data: the account's + * field schema, which the agent needs in order to interpret field values. + */ +export const ActiveCampaignField = z + .object({ + id: z.string(), + title: S, + descript: S, + type: S, + perstag: S, + defval: S, + isrequired: S, + show_in_list: S, + visible: S, + service: S, + ordernum: S, + rows: S, + cols: S, + cdate: S, + udate: S, + created_by: S, + updated_by: S, + created_timestamp: S, + updated_timestamp: S, + options: Unknown, + relations: Unknown, + links: Unknown, + }) + .loose(); + +/** + * A contact's membership of a list, including subscription status. Mirrored + * because membership is the state an agent segments on; it is updated in place + * rather than appended. + */ +export const ActiveCampaignContactList = z + .object({ + id: z.string(), + contact: S, + list: S, + status: S, + form: S, + seriesid: S, + sdate: S, + udate: S, + responder: S, + sync: S, + unsubreason: S, + campaign: S, + message: S, + first_name: S, + last_name: S, + ip4Sub: S, + ip4Unsub: S, + ip4_last: S, + sourceid: S, + autosyncLog: S, + unsubscribeAutomation: S, + automation: S, + channel: S, + created_by: S, + updated_by: S, + created_timestamp: S, + updated_timestamp: S, + links: Unknown, + }) + .loose(); + +/** + * The association between a contact and a tag. + */ +export const ActiveCampaignContactTag = z + .object({ + id: z.string(), + contact: S, + tag: S, + cdate: S, + created_by: S, + updated_by: S, + created_timestamp: S, + updated_timestamp: S, + links: Unknown, + }) + .loose(); + +/** + * A custom field *value* on a contact. Mirrored because agents segment on it - + * it is updated in place, not appended, so it is reference data rather than a + * transaction log. + */ +export const ActiveCampaignFieldValue = z + .object({ + id: z.string(), + contact: S, + field: S, + value: S, + owner: S, + cdate: S, + udate: S, + created_by: S, + updated_by: S, + links: Unknown, + }) + .loose(); + +/** + * One selectable option on a dropdown, radio, checkbox or listbox field. + */ +export const ActiveCampaignFieldOption = z + .object({ + id: z.string(), + field: S, + label: S, + value: S, + orderid: S, + isdefault: S, + cdate: S, + udate: S, + links: Unknown, + }) + .loose(); + +/** + * The association between a custom field and a list. + */ +export const ActiveCampaignFieldRel = z + .object({ + id: z.string(), + field: S, + relid: S, + dorder: S, + cdate: S, + links: Unknown, + }) + .loose(); + +/** + * The association between a custom field and the display group it appears in. + * + * The one resource observed returning real JSON numbers rather than strings - + * see the note at the top of this file. `id` is coerced because the local + * store keys entities by string. + */ +export const ActiveCampaignGroupMember = z + .object({ + id: z.coerce.string(), + rel_id: SN, + group_id: SN, + ordernum: SN, + links: Unknown, + }) + .loose(); + +/** + * A list-to-user-group permission grant. Reference data: it says which group + * may add, edit, delete or import against a given list. + */ +export const ActiveCampaignListGroup = z + .object({ + id: z.string(), + list: S, + group: S, + p_list_add: S, + p_list_edit: S, + p_list_delete: S, + p_list_filter: S, + p_list_sync: S, + p_message_add: S, + p_message_edit: S, + p_message_delete: S, + p_message_send: S, + p_subscriber_add: S, + p_subscriber_edit: S, + p_subscriber_delete: S, + p_subscriber_import: S, + p_subscriber_approve: S, + links: Unknown, + }) + .loose(); + +// --------------------------------------------------------------------------- +// CRM: deals +// +// Official list example: https://developers.activecampaign.com/reference/list-all-deals +// Official create example: https://developers.activecampaign.com/reference/create-a-deal-new +// Live 2026-08-14: deal, dealGroup and dealStage shapes captured after seeding +// a pipeline. Create vs list disagree on types for value/status/isDisabled. +// --------------------------------------------------------------------------- + +/** + * A deal. Official list example + live GET 2026-08-14. + * POST /deals returns `value` and `status` as JSON numbers; GET returns + * strings. `isDisabled` is a boolean on a full row and `1` on a permission- + * limited row. + */ +export const ActiveCampaignDeal = z + .object({ + /** Unique id of the deal. */ + id: z.string(), + /** Deal title. */ + title: S, + description: S, + /** Value in cents. Integer on create, string on list. */ + value: SN, + /** 3-letter ISO currency, lowercased. */ + currency: S, + /** 0 open, 1 won, 2 lost. Integer on create, string on list. */ + status: SN, + /** Primary contact id. */ + contact: S, + organization: S, + /** Pipeline (dealGroup) id. */ + group: S, + /** Stage id. */ + stage: S, + /** Owner user id. */ + owner: S, + percent: SN, + nextdate: S, + cdate: S, + mdate: S, + edate: S, + hash: S, + nextdealid: S, + nexttaskid: S, + activitycount: S, + winProbability: SN, + winProbabilityMdate: S, + /** False on a full row; `1` when pipeline permission is missing. */ + isDisabled: Flag, + account: SN, + customerAccount: SN, + fields: Unknown, + customFieldSaveStatus: Unknown, + links: Unknown, + }) + .loose(); + +/** + * A pipeline (deal group). Official create-pipeline + live GET 2026-08-14. + */ +export const ActiveCampaignDealGroup = z + .object({ + /** Unique id of the pipeline. */ + id: z.string(), + title: S, + currency: S, + allgroups: S, + allusers: S, + autoassign: S, + source: S, + count: S, + stages: Unknown, + win_probability_initialize_date: S, + cdate: S, + udate: S, + links: Unknown, + }) + .loose(); + +/** + * A pipeline stage. Official create-deal sideload + live GET 2026-08-14. + */ +export const ActiveCampaignDealStage = z + .object({ + /** Unique id of the stage. */ + id: z.string(), + title: S, + group: S, + order: S, + width: S, + color: S, + dealOrder: S, + cardRegion1: S, + cardRegion2: S, + cardRegion3: S, + cardRegion4: S, + cardRegion5: S, + cdate: S, + udate: S, + links: Unknown, + }) + .loose(); + +/** Shape not captured. */ +export const ActiveCampaignDealTask = z + .object({ + id: z.string(), + title: S, + note: S, + relid: S, + reltype: S, + dealtasktype: S, + duedate: S, + done: S, + status: S, + owner: S, + cdate: S, + edate: S, + links: Unknown, + }) + .loose(); + +/** Captured 2026-08-13: 13 keys. */ +export const ActiveCampaignDealTaskType = z + .object({ + id: z.string(), + title: S, + defduration: S, + display_order: S, + status: S, + cdate: S, + udate: S, + created_by: S, + updated_by: S, + created_utc_timestamp: S, + updated_utc_timestamp: S, + outcomes: Unknown, + links: Unknown, + }) + .loose(); + +/** Captured 2026-08-13: 5 keys. */ +export const ActiveCampaignDealRole = z + .object({ + id: z.string(), + title: S, + created_timestamp: S, + updated_timestamp: S, + links: Unknown, + }) + .loose(); + +/** A task outcome. Live GET 2026-08-14. */ +export const ActiveCampaignTaskOutcome = z + .object({ + id: z.string(), + title: S, + sentiment: S, + disabled: S, + created_by: S, + updated_by: S, + created_utc_timestamp: S, + updated_utc_timestamp: S, + dealTasktype_ids: Unknown, + links: Unknown, + }) + .loose(); + +/** + * A custom field definition for deals. + * + * Captured 2026-08-13: 17 keys, camelCase, and it returns real JSON numbers + * for `isFormVisible`, `isRequired`, `displayOrder`, `knownFieldId` and + * `hideFieldFlag` - see the note at the top of this file. + */ +export const ActiveCampaignDealCustomFieldMeta = z + .object({ + id: z.coerce.string(), + fieldLabel: S, + fieldType: S, + fieldDefault: S, + fieldDefaultCurrency: S, + fieldOptions: Unknown, + isFormVisible: SN, + isRequired: SN, + displayOrder: SN, + knownFieldId: SN, + hideFieldFlag: SN, + personalization: S, + createdBy: SN, + updatedBy: SN, + createdTimestamp: S, + updatedTimestamp: S, + links: Unknown, + }) + .loose(); + +/** Same shape as the deal variant. Captured 2026-08-13: 17 keys. */ +export const ActiveCampaignAccountCustomFieldMeta = + ActiveCampaignDealCustomFieldMeta; + +// --------------------------------------------------------------------------- +// CRM: accounts +// Official: https://developers.activecampaign.com/reference/list-all-accounts +// Live 2026-08-14: list row plus create extras (fields, customFieldSaveStatus). +// --------------------------------------------------------------------------- + +/** + * A CRM account (organization). Official list example + live GET 2026-08-14. + * `contactCount` / `dealCount` are strings; omitted unless `count_deals=true`. + */ +export const ActiveCampaignAccount = z + .object({ + /** Unique id of the account. */ + id: z.coerce.string(), + /** Account name. Must be unique. */ + name: S, + accountUrl: S, + owner: S, + contactCount: SN, + dealCount: SN, + createdTimestamp: S, + updatedTimestamp: S, + fields: Unknown, + customFieldSaveStatus: Unknown, + links: Unknown, + }) + .loose(); + +/** Shape not captured. */ +export const ActiveCampaignAccountContact = z + .object({ + id: z.coerce.string(), + contact: S, + account: S, + jobTitle: S, + createdTimestamp: S, + updatedTimestamp: S, + links: Unknown, + }) + .loose(); + +// --------------------------------------------------------------------------- +// Content: notes, campaigns, messages, templates, forms, personalizations +// --------------------------------------------------------------------------- + +/** + * A note. Official create-note + live GET 2026-08-14. + * `owner` is `{ type, id }`, not a string. + */ +export const ActiveCampaignNote = z + .object({ + id: z.string(), + note: S, + reltype: S, + relid: S, + cdate: S, + mdate: S, + userid: S, + user: S, + is_draft: S, + owner: Unknown, + links: Unknown, + }) + .loose(); + +/** Shape not captured - the trial account has sent no campaigns. */ +export const ActiveCampaignCampaign = z + .object({ + id: z.string(), + name: S, + type: S, + status: S, + sdate: S, + mdate: S, + ldate: S, + send_amt: S, + total_amt: S, + opens: S, + uniqueopens: S, + linkclicks: S, + uniquelinkclicks: S, + subscriberclicks: S, + unsubscribes: S, + hardbounces: S, + softbounces: S, + links: Unknown, + }) + .loose(); + +/** Captured 2026-08-13: 28 keys. */ +export const ActiveCampaignMessage = z + .object({ + id: z.string(), + name: S, + subject: S, + preheader_text: S, + fromname: S, + fromemail: S, + reply2: S, + html: S, + text: S, + htmlfetch: S, + textfetch: S, + charset: S, + encoding: S, + format: S, + language_code: S, + priority: S, + source: S, + hidden: S, + user: S, + userid: S, + cdate: S, + mdate: S, + ed_instanceid: S, + ed_version: S, + has_predictive_content: S, + preview_data: Unknown, + preview_mime: S, + links: Unknown, + }) + .loose(); + +/** Shape not captured. */ +export const ActiveCampaignTemplate = z + .object({ + id: z.string(), + name: S, + subject: S, + content: S, + categoryid: S, + userid: S, + cdate: S, + mdate: S, + links: Unknown, + }) + .loose(); + +/** Shape not captured. */ +export const ActiveCampaignSavedResponse = z + .object({ + id: z.string(), + title: S, + subject: S, + body: S, + userid: S, + cdate: S, + mdate: S, + links: Unknown, + }) + .loose(); + +/** Shape not captured. */ +export const ActiveCampaignForm = z + .object({ + id: z.string(), + name: S, + action: S, + layout: S, + style: S, + cdate: S, + udate: S, + links: Unknown, + }) + .loose(); + +/** A personalization variable. Shape not captured. */ +export const ActiveCampaignPersonalization = z + .object({ + id: z.string(), + name: S, + tag: S, + content: S, + format: S, + locked: S, + cdate: S, + udate: S, + links: Unknown, + }) + .loose(); + +// --------------------------------------------------------------------------- +// Automations and segments +// --------------------------------------------------------------------------- + +/** Shape not captured. */ +export const ActiveCampaignAutomation = z + .object({ + id: z.string(), + name: S, + status: S, + entered: S, + exited: S, + cdate: S, + mdate: S, + links: Unknown, + }) + .loose(); + +/** Captured 2026-08-13: 11 keys. */ +export const ActiveCampaignSegment = z + .object({ + id: z.string(), + name: S, + logic: S, + hidden: S, + seriesid: S, + segmentid_v2: S, + created_by: S, + updated_by: S, + created_timestamp: S, + updated_timestamp: S, + links: Unknown, + }) + .loose(); + +// --------------------------------------------------------------------------- +// E-commerce reference data +// +// Orders, order products and order activities are deliberately not mirrored: +// they are transactional, appended continuously, and only meaningful against a +// date range. Connections and customers are reference data and are mirrored. +// --------------------------------------------------------------------------- + +/** + * A Deep Data connection. Official create-connection + live GET 2026-08-14. + * `isInternal` and `listId` are integers on create, strings on list. + */ +export const ActiveCampaignConnection = z + .object({ + id: z.string(), + service: S, + serviceName: S, + externalid: S, + name: S, + logoUrl: S, + linkUrl: S, + status: S, + syncStatus: S, + connectionType: S, + isInternal: SN, + listId: SN, + planTier: S, + lastSync: S, + sync_request_time: S, + sync_start_time: S, + credentialExpiration: S, + disconnectDate: S, + cdate: S, + udate: S, + links: Unknown, + }) + .loose(); + +/** Shape not captured. */ +export const ActiveCampaignEcomCustomer = z + .object({ + id: z.string(), + connectionid: S, + externalid: S, + email: S, + totalRevenue: S, + totalOrders: S, + totalProducts: S, + avgRevenuePerOrder: S, + avgProductCategory: S, + acceptsMarketing: S, + links: Unknown, + }) + .loose(); + +// --------------------------------------------------------------------------- +// Account administration +// --------------------------------------------------------------------------- + +/** Captured 2026-08-13: 12 keys. */ +export const ActiveCampaignUser = z + .object({ + id: z.string(), + username: S, + email: S, + firstName: S, + lastName: S, + phone: S, + signature: S, + lang: S, + localZoneid: S, + mfaEnabled: SN, + roles: Unknown, + links: Unknown, + }) + .loose(); + +/** + * A permission group. Captured 2026-08-13 with 99 keys, nearly all of them + * individual `pg*` permission flags. Only the identifying fields are declared; + * the rest are preserved by `.loose()` rather than transcribed, because the + * flag set changes whenever ActiveCampaign ships a feature. + */ +export const ActiveCampaignGroup = z + .object({ + id: z.string(), + title: S, + descript: S, + p_admin: S, + sdate: S, + unsubscribelink: S, + optinconfirm: S, + socialdata: S, + reqApproval: S, + links: Unknown, + }) + .loose(); + +/** Captured 2026-08-13: 14 keys. */ +export const ActiveCampaignGroupLimit = z + .object({ + id: z.string(), + group: S, + groupid: S, + limitContact: S, + limitList: S, + limitCampaign: S, + limitCampaignType: S, + limitMail: S, + limitMailType: S, + limitUser: S, + limitAttachment: S, + abuseRatio: S, + forceSenderInfo: S, + links: Unknown, + }) + .loose(); + +/** + * A company address. Live POST+GET 2026-08-14. + * Official JSON key is `companyName`, not `company`. `isDefault` is an + * integer on create and a string on list. `allgroup` is the list key. + */ +export const ActiveCampaignAddress = z + .object({ + id: z.string(), + companyName: S, + address1: S, + address2: S, + city: S, + state: S, + district: S, + zip: S, + country: S, + isDefault: SN, + allgroup: S, + smsName: S, + created_by: S, + updated_by: S, + created_timestamp: S, + updated_timestamp: S, + links: Unknown, + }) + .loose(); + +/** + * A calendar feed. Live GET 2026-08-14. + */ +export const ActiveCampaignCalendar = z + .object({ + id: z.string(), + title: S, + type: S, + token: S, + userid: S, + notification: S, + cdate: S, + mdate: S, + links: Unknown, + }) + .loose(); + +/** Captured 2026-08-13: 8 keys. */ +export const ActiveCampaignScore = z + .object({ + id: z.string(), + name: S, + descript: S, + reltype: S, + status: S, + cdate: S, + mdate: S, + links: Unknown, + }) + .loose(); + +/** Captured 2026-08-13: 24 keys. */ +export const ActiveCampaignBranding = z + .object({ + id: z.string(), + siteName: S, + groupid: S, + siteLogo: S, + siteLogoSmall: S, + favicon: S, + copyright: S, + license: S, + help: S, + version: S, + headerHtmlValue: S, + headerTextValue: S, + footerHtmlValue: S, + footerTextValue: S, + publicTemplateCss: S, + publicTemplateHtm: S, + adminTemplateCss: S, + adminTemplateHtm: S, + admin_template_css_backup: S, + admin_template_htm_backup: S, + zendeskWidgetEnabled: S, + created_timestamp: S, + updated_timestamp: S, + links: Unknown, + }) + .loose(); + +/** A custom object schema. Shape not captured. */ +export const ActiveCampaignCustomObjectSchema = z + .object({ + id: z.coerce.string(), + slug: S, + name: S, + description: S, + labels: Unknown, + fields: Unknown, + relationships: Unknown, + createdTimestamp: S, + updatedTimestamp: S, + links: Unknown, + }) + .loose(); + +/** + * A webhook subscription. Official create-webhook + live GET 2026-08-14. + */ +export const ActiveCampaignWebhook = z + .object({ + id: z.string(), + name: S, + url: S, + events: Unknown, + sources: Unknown, + listid: S, + cdate: S, + state: S, + deactivated_date: S, + links: Unknown, + }) + .loose(); + +/** A whitelisted event-tracking event name. Shape not captured. */ +export const ActiveCampaignEventTrackingEvent = z + .object({ + id: z.string(), + name: S, + links: Unknown, + }) + .loose(); + +export type ActiveCampaignContact = z.infer; +export type ActiveCampaignListGroup = z.infer; +export type ActiveCampaignDeal = z.infer; +export type ActiveCampaignDealGroup = z.infer; +export type ActiveCampaignDealStage = z.infer; +export type ActiveCampaignDealTask = z.infer; +export type ActiveCampaignDealTaskType = z.infer< + typeof ActiveCampaignDealTaskType +>; +export type ActiveCampaignDealRole = z.infer; +export type ActiveCampaignTaskOutcome = z.infer< + typeof ActiveCampaignTaskOutcome +>; +export type ActiveCampaignDealCustomFieldMeta = z.infer< + typeof ActiveCampaignDealCustomFieldMeta +>; +export type ActiveCampaignAccount = z.infer; +export type ActiveCampaignAccountContact = z.infer< + typeof ActiveCampaignAccountContact +>; +export type ActiveCampaignNote = z.infer; +export type ActiveCampaignCampaign = z.infer; +export type ActiveCampaignMessage = z.infer; +export type ActiveCampaignTemplate = z.infer; +export type ActiveCampaignSavedResponse = z.infer< + typeof ActiveCampaignSavedResponse +>; +export type ActiveCampaignForm = z.infer; +export type ActiveCampaignPersonalization = z.infer< + typeof ActiveCampaignPersonalization +>; +export type ActiveCampaignAutomation = z.infer; +export type ActiveCampaignSegment = z.infer; +export type ActiveCampaignConnection = z.infer; +export type ActiveCampaignEcomCustomer = z.infer< + typeof ActiveCampaignEcomCustomer +>; +export type ActiveCampaignUser = z.infer; +export type ActiveCampaignGroup = z.infer; +export type ActiveCampaignGroupLimit = z.infer; +export type ActiveCampaignAddress = z.infer; +export type ActiveCampaignCalendar = z.infer; +export type ActiveCampaignScore = z.infer; +export type ActiveCampaignBranding = z.infer; +export type ActiveCampaignCustomObjectSchema = z.infer< + typeof ActiveCampaignCustomObjectSchema +>; +export type ActiveCampaignWebhook = z.infer; +export type ActiveCampaignEventTrackingEvent = z.infer< + typeof ActiveCampaignEventTrackingEvent +>; +export type ActiveCampaignFieldValue = z.infer; +export type ActiveCampaignFieldOption = z.infer< + typeof ActiveCampaignFieldOption +>; +export type ActiveCampaignFieldRel = z.infer; +export type ActiveCampaignGroupMember = z.infer< + typeof ActiveCampaignGroupMember +>; +export type ActiveCampaignList = z.infer; +export type ActiveCampaignTag = z.infer; +export type ActiveCampaignField = z.infer; +export type ActiveCampaignContactList = z.infer< + typeof ActiveCampaignContactList +>; +export type ActiveCampaignContactTag = z.infer; diff --git a/packages/activecampaign/schema/index.ts b/packages/activecampaign/schema/index.ts new file mode 100644 index 000000000..f6911072d --- /dev/null +++ b/packages/activecampaign/schema/index.ts @@ -0,0 +1,159 @@ +import { + ActiveCampaignAccount, + ActiveCampaignAccountContact, + ActiveCampaignAccountCustomFieldMeta, + ActiveCampaignAddress, + ActiveCampaignAutomation, + ActiveCampaignBranding, + ActiveCampaignCalendar, + ActiveCampaignCampaign, + ActiveCampaignConnection, + ActiveCampaignContact, + ActiveCampaignContactList, + ActiveCampaignContactTag, + ActiveCampaignCustomObjectSchema, + ActiveCampaignDeal, + ActiveCampaignDealCustomFieldMeta, + ActiveCampaignDealGroup, + ActiveCampaignDealRole, + ActiveCampaignDealStage, + ActiveCampaignDealTask, + ActiveCampaignDealTaskType, + ActiveCampaignEcomCustomer, + ActiveCampaignEventTrackingEvent, + ActiveCampaignField, + ActiveCampaignFieldOption, + ActiveCampaignFieldRel, + ActiveCampaignFieldValue, + ActiveCampaignForm, + ActiveCampaignGroup, + ActiveCampaignGroupLimit, + ActiveCampaignGroupMember, + ActiveCampaignList, + ActiveCampaignListGroup, + ActiveCampaignMessage, + ActiveCampaignNote, + ActiveCampaignPersonalization, + ActiveCampaignSavedResponse, + ActiveCampaignScore, + ActiveCampaignSegment, + ActiveCampaignTag, + ActiveCampaignTaskOutcome, + ActiveCampaignTemplate, + ActiveCampaignUser, + ActiveCampaignWebhook, +} from './database'; + +/** + * Entities mirrored into the local store. + * + * All of these are reference data - records that are created once and updated + * in place, which an agent needs to resolve ids and to segment on. + * + * Deliberately absent, because they are transactional rather than reference: + * deal activities, email activities, contact automations, e-commerce orders, + * order products and order activities, campaign links, and the account config + * key/value store. Those are appended continuously and are only meaningful + * against a date range, so mirroring them would grow without bound while + * answering nothing a live call cannot. + */ +export const ActiveCampaignSchema = { + version: '1.0.0', + entities: { + // Contacts and their attributes + contacts: ActiveCampaignContact, + lists: ActiveCampaignList, + tags: ActiveCampaignTag, + fields: ActiveCampaignField, + contactLists: ActiveCampaignContactList, + contactTags: ActiveCampaignContactTag, + fieldValues: ActiveCampaignFieldValue, + fieldOptions: ActiveCampaignFieldOption, + fieldRels: ActiveCampaignFieldRel, + groupMembers: ActiveCampaignGroupMember, + listGroups: ActiveCampaignListGroup, + // CRM + deals: ActiveCampaignDeal, + dealGroups: ActiveCampaignDealGroup, + dealStages: ActiveCampaignDealStage, + dealTasks: ActiveCampaignDealTask, + dealTaskTypes: ActiveCampaignDealTaskType, + dealRoles: ActiveCampaignDealRole, + taskOutcomes: ActiveCampaignTaskOutcome, + dealCustomFieldMeta: ActiveCampaignDealCustomFieldMeta, + accounts: ActiveCampaignAccount, + accountContacts: ActiveCampaignAccountContact, + accountCustomFieldMeta: ActiveCampaignAccountCustomFieldMeta, + // Content + notes: ActiveCampaignNote, + campaigns: ActiveCampaignCampaign, + messages: ActiveCampaignMessage, + templates: ActiveCampaignTemplate, + savedResponses: ActiveCampaignSavedResponse, + forms: ActiveCampaignForm, + personalizations: ActiveCampaignPersonalization, + // Automations and segments + automations: ActiveCampaignAutomation, + segments: ActiveCampaignSegment, + // E-commerce reference data + connections: ActiveCampaignConnection, + ecomCustomers: ActiveCampaignEcomCustomer, + // Custom objects + customObjectSchemas: ActiveCampaignCustomObjectSchema, + // Account administration + users: ActiveCampaignUser, + groups: ActiveCampaignGroup, + groupLimits: ActiveCampaignGroupLimit, + addresses: ActiveCampaignAddress, + calendars: ActiveCampaignCalendar, + scores: ActiveCampaignScore, + brandings: ActiveCampaignBranding, + webhooks: ActiveCampaignWebhook, + eventTrackingEvents: ActiveCampaignEventTrackingEvent, + }, +} as const; + +export type { + ActiveCampaignAccount, + ActiveCampaignAccountContact, + ActiveCampaignAddress, + ActiveCampaignAutomation, + ActiveCampaignBranding, + ActiveCampaignCalendar, + ActiveCampaignCampaign, + ActiveCampaignConnection, + ActiveCampaignContact, + ActiveCampaignContactList, + ActiveCampaignContactTag, + ActiveCampaignCustomObjectSchema, + ActiveCampaignDeal, + ActiveCampaignDealCustomFieldMeta, + ActiveCampaignDealGroup, + ActiveCampaignDealRole, + ActiveCampaignDealStage, + ActiveCampaignDealTask, + ActiveCampaignDealTaskType, + ActiveCampaignEcomCustomer, + ActiveCampaignEventTrackingEvent, + ActiveCampaignField, + ActiveCampaignFieldOption, + ActiveCampaignFieldRel, + ActiveCampaignFieldValue, + ActiveCampaignForm, + ActiveCampaignGroup, + ActiveCampaignGroupLimit, + ActiveCampaignGroupMember, + ActiveCampaignList, + ActiveCampaignListGroup, + ActiveCampaignMessage, + ActiveCampaignNote, + ActiveCampaignPersonalization, + ActiveCampaignSavedResponse, + ActiveCampaignScore, + ActiveCampaignSegment, + ActiveCampaignTag, + ActiveCampaignTaskOutcome, + ActiveCampaignTemplate, + ActiveCampaignUser, + ActiveCampaignWebhook, +} from './database'; diff --git a/packages/activecampaign/segments-v2.test.ts b/packages/activecampaign/segments-v2.test.ts new file mode 100644 index 000000000..094553b9f --- /dev/null +++ b/packages/activecampaign/segments-v2.test.ts @@ -0,0 +1,69 @@ +import { UNVERIFIED_ROUTES } from './endpoints/segments-v2'; +import { activecampaignEndpointMeta } from './index'; + +/** + * Keeps the honesty marker honest. + * + * `UNVERIFIED_ROUTES` records the operations whose URL could not be confirmed + * against a live ActiveCampaign account. It is quoted in the PR body, so it + * has to stay in step with the registry: an entry naming an operation that no + * longer exists would understate the risk, and a V2 segment operation missing + * from the set would hide it entirely. + */ + +const META = activecampaignEndpointMeta as Record< + string, + { riskLevel: string; description: string } +>; + +const toKey = (path: string) => + path.replace(/\.(.)/g, (_m, c: string) => c.toUpperCase()); + +describe('unverified route declaration', () => { + const registered = Object.keys(META).map(toKey); + + it('lists exactly the fourteen V2 segment operations', () => { + expect(UNVERIFIED_ROUTES.size).toBe(14); + }); + + it('names only operations that actually exist', () => { + expect(registered.length).toBeGreaterThan(0); + for (const op of UNVERIFIED_ROUTES) { + expect(registered).toContain(op); + } + }); + + /** + * Every V2 segment operation must be declared unverified. If one is added + * later against a confirmed route, remove it from the set deliberately - + * this test failing is the prompt to do that. + */ + it('covers every V2 segment operation in the registry', () => { + const v2 = registered.filter((k) => k.startsWith('segmentsV2')); + expect(v2).toHaveLength(14); + for (const op of v2) { + expect(UNVERIFIED_ROUTES.has(op)).toBe(true); + } + }); + + /** + * The legacy `/segments` collection answers 200 on a live account and is + * implemented separately, so it must not be marked unverified. + */ + it('does not mark the verified legacy segment operations', () => { + const legacy = registered.filter( + (k) => k.startsWith('segments') && !k.startsWith('segmentsV2'), + ); + expect(legacy.length).toBeGreaterThan(0); + for (const op of legacy) { + expect(UNVERIFIED_ROUTES.has(op)).toBe(false); + } + }); + + it('accounts for under 5 per cent of the operation surface', () => { + // A sanity bound: if this ever grows large, the plugin has drifted from + // being evidence-based and the PR body claim needs rewriting. + const share = UNVERIFIED_ROUTES.size / Object.keys(META).length; + expect(share).toBeLessThan(0.05); + }); +}); diff --git a/packages/activecampaign/tsconfig.json b/packages/activecampaign/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/activecampaign/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/activecampaign/tsup.config.ts b/packages/activecampaign/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/activecampaign/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 7f168fd0c..d7c6415e4 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -14,6 +14,7 @@ export type AllErrors = export const BaseProviders = [ 'abstract', + 'activecampaign', 'activetrail', 'addresszen', 'affinda', @@ -139,6 +140,7 @@ export const BaseProviders = [ export const ProviderDisplayNames = { abstract: 'Abstract', + activecampaign: 'ActiveCampaign', activetrail: 'Active Trail', addresszen: 'Addresszen', affinda: 'Affinda', @@ -271,6 +273,7 @@ export function formatProviderDisplayName(plugin: string): string { export type AllProviders = | 'abstract' + | 'activecampaign' | 'activetrail' | 'addresszen' | 'affinda' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2e91d2ba..3b8f3958a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/activecampaign: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/activetrail: devDependencies: '@types/jest': @@ -3629,7 +3653,7 @@ importers: version: link:../packages/slack '@next/third-parties': specifier: 15.3.2 - version: 15.3.2(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 15.3.2(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -3653,7 +3677,7 @@ importers: version: 11.8.1(typescript@5.9.3) better-auth: specifier: ^1.5.5 - version: 1.6.15(@cloudflare/workers-types@4.20251121.0)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.5.0)(bun-types@1.3.3)(gel@2.2.0)(kysely@0.28.17)(mysql2@3.15.3)(pg@8.21.0)(postgres@3.4.7)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(sqlite3@5.1.7))(mysql2@3.15.3)(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.21.0)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.6.15(@cloudflare/workers-types@4.20251121.0)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.5.0)(bun-types@1.3.3)(gel@2.2.0)(kysely@0.28.17)(mysql2@3.15.3)(pg@8.21.0)(postgres@3.4.7)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(sqlite3@5.1.7))(mysql2@3.15.3)(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.21.0)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -3671,13 +3695,13 @@ importers: version: 0.44.7(@cloudflare/workers-types@4.20251121.0)(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.5.0)(bun-types@1.3.3)(gel@2.2.0)(kysely@0.28.17)(mysql2@3.15.3)(pg@8.21.0)(postgres@3.4.7)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(sqlite3@5.1.7) inngest: specifier: ^3.54.0 - version: 3.54.2(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(express@5.2.1)(hono@4.12.2)(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(zod@4.4.3) + version: 3.54.2(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(express@5.2.1)(hono@4.12.2)(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(zod@4.4.3) next: specifier: ^15.3.2 - version: 15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-sanity: specifier: ^11.6.13 - version: 11.6.13(@emotion/is-prop-valid@1.4.0)(@sanity/client@7.23.0)(@sanity/icons@3.7.4(react@19.2.5))(@sanity/types@6.2.0(@types/react@19.2.6))(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3) + version: 11.6.13(@emotion/is-prop-valid@1.4.0)(@sanity/client@7.23.0)(@sanity/icons@3.7.4(react@19.2.5))(@sanity/types@4.22.0(@types/react@19.2.6))(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3) pg: specifier: ^8.20.0 version: 8.21.0 @@ -4037,10 +4061,6 @@ packages: resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -4746,10 +4766,6 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -15071,7 +15087,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-annotate-as-pure@7.29.7': dependencies: @@ -15153,13 +15169,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.28.6 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7 @@ -15419,6 +15428,11 @@ snapshots: '@babel/core': 7.28.6 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -15805,7 +15819,7 @@ snapshots: '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': dependencies: @@ -15829,31 +15843,31 @@ snapshots: '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) - '@babel/types': 7.28.6 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.6) + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -15872,7 +15886,7 @@ snapshots: dependencies: '@babel/core': 7.28.6 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7)': dependencies: @@ -16153,11 +16167,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -17709,9 +17718,9 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.18': optional: true - '@next/third-parties@15.3.2(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + '@next/third-parties@15.3.2(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': dependencies: - next: 15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 third-party-capital: 1.0.20 @@ -19874,20 +19883,6 @@ snapshots: - '@emotion/is-prop-valid' - styled-components - '@sanity/insert-menu@2.1.0(@emotion/is-prop-valid@1.4.0)(@sanity/types@6.2.0(@types/react@19.2.6))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': - dependencies: - '@sanity/icons': 3.7.4(react@19.2.5) - '@sanity/types': 6.2.0(@types/react@19.2.6) - '@sanity/ui': 3.2.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) - lodash: 4.18.1 - react: 19.2.5 - react-compiler-runtime: 1.0.0(react@19.2.5) - react-dom: 19.2.5(react@19.2.5) - react-is: 19.2.7 - transitivePeerDependencies: - - '@emotion/is-prop-valid' - - styled-components - '@sanity/json-match@1.0.5': {} '@sanity/logos@2.2.2(react@19.2.5)': @@ -19984,14 +19979,6 @@ snapshots: - '@sanity/client' - '@sanity/types' - '@sanity/presentation-comlink@2.1.0(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6))': - dependencies: - '@sanity/comlink': 4.0.1 - '@sanity/visual-editing-types': 1.1.8(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6)) - transitivePeerDependencies: - - '@sanity/client' - - '@sanity/types' - '@sanity/preview-url-secret@3.0.0(@sanity/client@7.23.0)(@sanity/icons@3.7.4(react@19.2.5))(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))': dependencies: '@sanity/client': 7.23.0 @@ -20212,10 +20199,10 @@ snapshots: - react-dom - react-is - '@sanity/visual-editing-csm@2.0.26(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6))(typescript@5.9.3)': + '@sanity/visual-editing-csm@2.0.26(@sanity/client@7.23.0)(@sanity/types@4.22.0(@types/react@19.2.6))(typescript@5.9.3)': dependencies: '@sanity/client': 7.23.0 - '@sanity/visual-editing-types': 1.1.8(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6)) + '@sanity/visual-editing-types': 1.1.8(@sanity/client@7.23.0)(@sanity/types@4.22.0(@types/react@19.2.6)) valibot: 1.4.1(typescript@5.9.3) transitivePeerDependencies: - '@sanity/types' @@ -20227,22 +20214,16 @@ snapshots: optionalDependencies: '@sanity/types': 4.22.0(@types/react@19.2.6) - '@sanity/visual-editing-types@1.1.8(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6))': - dependencies: - '@sanity/client': 7.23.0 - optionalDependencies: - '@sanity/types': 6.2.0(@types/react@19.2.6) - - '@sanity/visual-editing@4.0.3(@emotion/is-prop-valid@1.4.0)(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6))(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)': + '@sanity/visual-editing@4.0.3(@emotion/is-prop-valid@1.4.0)(@sanity/client@7.23.0)(@sanity/types@4.22.0(@types/react@19.2.6))(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)': dependencies: '@sanity/comlink': 4.0.1 '@sanity/icons': 3.7.4(react@19.2.5) - '@sanity/insert-menu': 2.1.0(@emotion/is-prop-valid@1.4.0)(@sanity/types@6.2.0(@types/react@19.2.6))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@sanity/insert-menu': 2.1.0(@emotion/is-prop-valid@1.4.0)(@sanity/types@4.22.0(@types/react@19.2.6))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@sanity/mutate': 0.11.0-canary.4(xstate@5.32.2) - '@sanity/presentation-comlink': 2.1.0(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6)) + '@sanity/presentation-comlink': 2.1.0(@sanity/client@7.23.0)(@sanity/types@4.22.0(@types/react@19.2.6)) '@sanity/preview-url-secret': 3.0.0(@sanity/client@7.23.0)(@sanity/icons@3.7.4(react@19.2.5))(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0)) '@sanity/ui': 3.2.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) - '@sanity/visual-editing-csm': 2.0.26(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6))(typescript@5.9.3) + '@sanity/visual-editing-csm': 2.0.26(@sanity/client@7.23.0)(@sanity/types@4.22.0(@types/react@19.2.6))(typescript@5.9.3) '@vercel/stega': 1.0.0 react: 19.2.5 react-compiler-runtime: 1.0.0(react@19.2.5) @@ -20255,7 +20236,7 @@ snapshots: xstate: 5.32.2 optionalDependencies: '@sanity/client': 7.23.0 - next: 15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@sanity/types' @@ -20614,8 +20595,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -21299,7 +21280,7 @@ snapshots: before-after-hook@4.0.0: {} - better-auth@1.6.15(@cloudflare/workers-types@4.20251121.0)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.5.0)(bun-types@1.3.3)(gel@2.2.0)(kysely@0.28.17)(mysql2@3.15.3)(pg@8.21.0)(postgres@3.4.7)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(sqlite3@5.1.7))(mysql2@3.15.3)(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.21.0)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + better-auth@1.6.15(@cloudflare/workers-types@4.20251121.0)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.5.0)(bun-types@1.3.3)(gel@2.2.0)(kysely@0.28.17)(mysql2@3.15.3)(pg@8.21.0)(postgres@3.4.7)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(sqlite3@5.1.7))(mysql2@3.15.3)(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.21.0)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@better-auth/core': 1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20251121.0)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.1)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20251121.0)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.1)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.5.0)(bun-types@1.3.3)(gel@2.2.0)(kysely@0.28.17)(mysql2@3.15.3)(pg@8.21.0)(postgres@3.4.7)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(sqlite3@5.1.7)) @@ -21324,7 +21305,7 @@ snapshots: drizzle-kit: 0.31.8 drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20251121.0)(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.5.0)(bun-types@1.3.3)(gel@2.2.0)(kysely@0.28.17)(mysql2@3.15.3)(pg@8.21.0)(postgres@3.4.7)(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(sqlite3@5.1.7) mysql2: 3.15.3 - next: 15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) pg: 8.21.0 prisma: 6.19.0(magicast@0.3.5)(typescript@5.9.3) react: 19.2.5 @@ -21771,8 +21752,7 @@ snapshots: confbox@0.2.2: {} - confbox@0.2.4: - optional: true + confbox@0.2.4: {} config-chain@1.1.13: dependencies: @@ -23209,7 +23189,7 @@ snapshots: - encoding - supports-color - inngest@3.54.2(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(express@5.2.1)(hono@4.12.2)(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(zod@4.4.3): + inngest@3.54.2(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(express@5.2.1)(hono@4.12.2)(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(zod@4.4.3): dependencies: '@bufbuild/protobuf': 2.11.0 '@inngest/ai': 0.1.7 @@ -23240,7 +23220,7 @@ snapshots: optionalDependencies: express: 5.2.1 hono: 4.12.2 - next: 15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) typescript: 5.9.3 transitivePeerDependencies: - '@opentelemetry/core' @@ -24489,18 +24469,18 @@ snapshots: neo-async@2.6.2: {} - next-sanity@11.6.13(@emotion/is-prop-valid@1.4.0)(@sanity/client@7.23.0)(@sanity/icons@3.7.4(react@19.2.5))(@sanity/types@6.2.0(@types/react@19.2.6))(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3): + next-sanity@11.6.13(@emotion/is-prop-valid@1.4.0)(@sanity/client@7.23.0)(@sanity/icons@3.7.4(react@19.2.5))(@sanity/types@4.22.0(@types/react@19.2.6))(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3): dependencies: '@portabletext/react': 6.2.0(react@19.2.5) '@sanity/client': 7.23.0 '@sanity/comlink': 4.0.1 - '@sanity/presentation-comlink': 2.1.0(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6)) + '@sanity/presentation-comlink': 2.1.0(@sanity/client@7.23.0)(@sanity/types@4.22.0(@types/react@19.2.6)) '@sanity/preview-url-secret': 3.0.0(@sanity/client@7.23.0)(@sanity/icons@3.7.4(react@19.2.5))(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0)) - '@sanity/visual-editing': 4.0.3(@emotion/is-prop-valid@1.4.0)(@sanity/client@7.23.0)(@sanity/types@6.2.0(@types/react@19.2.6))(next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3) + '@sanity/visual-editing': 4.0.3(@emotion/is-prop-valid@1.4.0)(@sanity/client@7.23.0)(@sanity/types@4.22.0(@types/react@19.2.6))(next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-is@19.2.7)(react@19.2.5)(sanity@4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0))(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3) dequal: 2.0.3 groq: 4.22.0 history: 5.3.0 - next: 15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) sanity: 4.22.0(@emotion/is-prop-valid@1.4.0)(@portabletext/sanity-bridge@1.2.14(@types/react@19.2.6))(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(styled-components@6.4.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) @@ -24544,7 +24524,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@15.5.18(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 @@ -24552,7 +24532,7 @@ snapshots: postcss: 8.4.31 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.5) + styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.5) optionalDependencies: '@next/swc-darwin-arm64': 15.5.18 '@next/swc-darwin-x64': 15.5.18 @@ -25010,7 +24990,7 @@ snapshots: pkg-types@2.3.0: dependencies: - confbox: 0.2.2 + confbox: 0.2.4 exsolve: 1.0.8 pathe: 2.0.3 @@ -25084,7 +25064,7 @@ snapshots: postcss@8.5.6: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -26403,12 +26383,12 @@ snapshots: client-only: 0.0.1 react: 18.3.1 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.5): + styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.5): dependencies: client-only: 0.0.1 react: 19.2.5 optionalDependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.28.6 stylis@4.3.6: {}