diff --git a/packages/destination-actions/src/destinations/attio/api/index.ts b/packages/destination-actions/src/destinations/attio/api/index.ts index 522c152168..7832974977 100644 --- a/packages/destination-actions/src/destinations/attio/api/index.ts +++ b/packages/destination-actions/src/destinations/attio/api/index.ts @@ -3,6 +3,17 @@ import { ModifiedResponse } from '@segment/actions-core' import get from 'lodash/get' import sortBy from 'lodash/sortBy' +export type SimpleValue = string | number | boolean + +type BatchAssertion = { + object: string + mode: 'create-or-update' + matching_attribute: string + multiselect_values: 'append' + values: Record | BatchAssertion | Array> + received_at: string +} + export type AssertResponse = { data: { id: { @@ -59,6 +70,26 @@ export class AttioClient { ) } + /** + * Send a series of (nested) assertions in a single HTTP call + * + * @param assertions One or more assertions to apply + * @param requestOptions Additional options for the request + */ + async batchAssert({ + assertions, + requestOptions + }: { + assertions: Array + requestOptions?: Partial + }): Promise> { + return await this.request(`${this.api_url}/v2/batch/records`, { + method: 'put', + json: { assertions }, + ...requestOptions + }) + } + /** * List all of the available Objects in the Attio workspace. */ diff --git a/packages/destination-actions/src/destinations/attio/assertRecord/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attio/assertRecord/__tests__/index.test.ts index e0440d3b5e..3356afaea8 100644 --- a/packages/destination-actions/src/destinations/attio/assertRecord/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/attio/assertRecord/__tests__/index.test.ts @@ -13,7 +13,8 @@ const event = createTestEvent({ traits: { name: 'Stair car', number_of_wheels: 4 - } + }, + receivedAt: '2024-05-24T10:00:00.000Z' }) const mapping = { @@ -26,6 +27,9 @@ const mapping = { number_of_wheels: { '@path': '$.traits.number_of_wheels' } + }, + received_at: { + '@path': '$.receivedAt' } } @@ -109,4 +113,58 @@ describe('Attio.assertRecord', () => { }) }) }) + + it('uses the batch assertion endpoint', async () => { + nock('https://api.attio.com') + .put('/v2/batch/records', { + assertions: [ + { + object: 'vehicles', + mode: 'create-or-update', + matching_attribute: 'name', + multiselect_values: 'append', + values: { + name: 'Stair car', + number_of_wheels: 4 + }, + received_at: '2024-05-24T10:00:00.000Z' + } + ] + }) + .reply(202, '') + + const [response] = await testDestination.testBatchAction('assertRecord', { + events: [event], + mapping, + settings: {} + }) + + expect(response.status).toBe(202) + }) + + it('handles the case where receivedAt is not provided', async () => { + const lackingReceivedAtEvent = createTestEvent({ + type: 'track' as const, + traits: { + name: 'Stair car', + number_of_wheels: 4 + }, + receivedAt: undefined + }) + + // Can't control the exact timestamp, so only check it starts on the same year-month-day and is ISO8601 formatted + const datePrefix = new Date().toISOString().split('T')[0] + + nock('https://api.attio.com') + .put('/v2/batch/records', new RegExp(`"received_at":"${datePrefix}T`)) + .reply(202, '') + + const [response] = await testDestination.testBatchAction('assertRecord', { + events: [lackingReceivedAtEvent], + mapping, + settings: {} + }) + + expect(response.status).toBe(202) + }) }) diff --git a/packages/destination-actions/src/destinations/attio/assertRecord/generated-types.ts b/packages/destination-actions/src/destinations/attio/assertRecord/generated-types.ts index b6d4a0b501..56e2b78b8c 100644 --- a/packages/destination-actions/src/destinations/attio/assertRecord/generated-types.ts +++ b/packages/destination-actions/src/destinations/attio/assertRecord/generated-types.ts @@ -15,4 +15,16 @@ export interface Payload { attributes?: { [k: string]: unknown } + /** + * Send data to Attio in batches for much better performance. + */ + enable_batching?: boolean + /** + * Max batch size to send to Attio (limit is 10,000) + */ + batch_size?: number + /** + * When the event was received. + */ + received_at?: string | number } diff --git a/packages/destination-actions/src/destinations/attio/assertRecord/index.ts b/packages/destination-actions/src/destinations/attio/assertRecord/index.ts index 20d04abcf7..b784e77ba1 100644 --- a/packages/destination-actions/src/destinations/attio/assertRecord/index.ts +++ b/packages/destination-actions/src/destinations/attio/assertRecord/index.ts @@ -2,7 +2,8 @@ import type { ActionDefinition, DynamicFieldResponse, RequestClient } from '@seg import type { InputField } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { AttioClient } from '../api' +import { AttioClient, SimpleValue } from '../api' +import { commonFields } from '../common-fields' const object: InputField = { type: 'string', @@ -68,7 +69,8 @@ const action: ActionDefinition = { fields: { object, matching_attribute, - attributes + attributes, + ...commonFields }, dynamicFields: { @@ -83,6 +85,21 @@ const action: ActionDefinition = { matching_attribute: payload.matching_attribute, values: payload.attributes ?? {} }) + }, + + performBatch: async (request, { payload }) => { + const client = new AttioClient(request) + + return await client.batchAssert({ + assertions: payload.map((item) => ({ + object: item.object, + mode: 'create-or-update', + matching_attribute: item.matching_attribute, + multiselect_values: 'append', + values: (item.attributes as Record) ?? {}, + received_at: item.received_at?.toString() ?? new Date().toISOString() + })) + }) } } diff --git a/packages/destination-actions/src/destinations/attio/common-fields.ts b/packages/destination-actions/src/destinations/attio/common-fields.ts new file mode 100644 index 0000000000..0911471da9 --- /dev/null +++ b/packages/destination-actions/src/destinations/attio/common-fields.ts @@ -0,0 +1,32 @@ +import { ActionDefinition } from '@segment/actions-core' +import { Settings } from './generated-types' + +export const commonFields: ActionDefinition['fields'] = { + enable_batching: { + label: 'Send data to Attio in batches', + description: + 'Send batches of events to Attio, for improved performance, however invalid events are silently dropped.', + type: 'boolean', + required: false, + default: false + }, + + batch_size: { + label: 'Batch Size', + description: 'Max batch size to send to Attio (limit is 10,000)', + type: 'number', + required: false, + unsafe_hidden: true, + default: 1_000 + }, + + received_at: { + label: 'Received at', + description: 'When the event was received.', + type: 'datetime', + required: false, + default: { + '@path': '$.receivedAt' + } + } +} diff --git a/packages/destination-actions/src/destinations/attio/groupWorkspace/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attio/groupWorkspace/__tests__/index.test.ts index fea8b6f6e1..c285576a35 100644 --- a/packages/destination-actions/src/destinations/attio/groupWorkspace/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/attio/groupWorkspace/__tests__/index.test.ts @@ -12,13 +12,17 @@ const event = createTestEvent({ traits: { id: '42', domain - } + }, + receivedAt: '2024-05-24T10:00:00.000Z' }) const mapping = { domain: { '@path': '$.traits.domain' }, workspace_id: { '@path': '$.traits.id' }, - user_id: { '@path': '$.userId' } + user_id: { '@path': '$.userId' }, + received_at: { + '@path': '$.receivedAt' + } } describe('Attio.groupWorkspace', () => { @@ -180,4 +184,68 @@ describe('Attio.groupWorkspace', () => { }) ).rejects.toThrowError() }) + + it('uses the batch assertion endpoint', async () => { + nock('https://api.attio.com') + .put('/v2/batch/records', { + assertions: [ + { + object: 'workspaces', + mode: 'create-or-update', + matching_attribute: 'workspace_id', + multiselect_values: 'append', + values: { + workspace_id: '42', + users: ['user1234'], + + company: { + object: 'companies', + mode: 'create-or-update', + matching_attribute: 'domains', + multiselect_values: 'append', + values: { + domains: domain + }, + received_at: '2024-05-24T10:00:00.000Z' + } + }, + received_at: '2024-05-24T10:00:00.000Z' + } + ] + }) + .reply(202, '') + + const responses = await testDestination.testBatchAction('groupWorkspace', { + events: [event], + mapping, + settings: {} + }) + + expect(responses.length).toBe(2) + expect(responses[1].status).toBe(202) + }) + + it('handles the case where receivedAt is not provided', async () => { + const lackingReceivedAtEvent = createTestEvent({ + type: 'group' as const, + traits: { + id: '42', + domain + }, + receivedAt: undefined + }) + + // Can't control the exact timestamp, so only check it starts on the same year-month-day and is ISO8601 formatted + const datePrefix = new Date().toISOString().split('T')[0] + + nock('https://api.attio.com') + .put('/v2/batch/records', new RegExp(`"received_at":"${datePrefix}T`)) + .reply(202, '') + + await testDestination.testBatchAction('groupWorkspace', { + events: [lackingReceivedAtEvent], + mapping, + settings: {} + }) + }) }) diff --git a/packages/destination-actions/src/destinations/attio/groupWorkspace/generated-types.ts b/packages/destination-actions/src/destinations/attio/groupWorkspace/generated-types.ts index e2854dc918..e5014806b0 100644 --- a/packages/destination-actions/src/destinations/attio/groupWorkspace/generated-types.ts +++ b/packages/destination-actions/src/destinations/attio/groupWorkspace/generated-types.ts @@ -25,4 +25,16 @@ export interface Payload { workspace_attributes?: { [k: string]: unknown } + /** + * Send data to Attio in batches for much better performance. + */ + enable_batching?: boolean + /** + * Max batch size to send to Attio (limit is 10,000) + */ + batch_size?: number + /** + * When the event was received. + */ + received_at?: string | number } diff --git a/packages/destination-actions/src/destinations/attio/groupWorkspace/index.ts b/packages/destination-actions/src/destinations/attio/groupWorkspace/index.ts index 7780c4386d..f02f0d19cf 100644 --- a/packages/destination-actions/src/destinations/attio/groupWorkspace/index.ts +++ b/packages/destination-actions/src/destinations/attio/groupWorkspace/index.ts @@ -3,6 +3,7 @@ import type { InputField } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { AttioClient } from '../api' +import { commonFields } from '../common-fields' const domain: InputField = { type: 'string', @@ -76,7 +77,8 @@ const action: ActionDefinition = { workspace_id, user_id, company_attributes, - workspace_attributes + workspace_attributes, + ...commonFields }, perform: async (request, { payload }) => { @@ -101,6 +103,37 @@ const action: ActionDefinition = { ...(payload.workspace_attributes ?? {}) } }) + }, + + performBatch: async (request, { payload }) => { + const client = new AttioClient(request) + + return await client.batchAssert({ + assertions: payload.map((item) => ({ + object: 'workspaces', + mode: 'create-or-update', + matching_attribute: 'workspace_id', + multiselect_values: 'append', + values: { + workspace_id: item.workspace_id, + ...(item.user_id ? { users: [item.user_id] } : {}), + ...(item.workspace_attributes ?? {}), + + company: { + object: 'companies', + mode: 'create-or-update', + matching_attribute: 'domains', + multiselect_values: 'append', + values: { + domains: item.domain, + ...(item.company_attributes ?? {}) + }, + received_at: item.received_at?.toString() ?? new Date().toISOString() + } + }, + received_at: item.received_at?.toString() ?? new Date().toISOString() + })) + }) } } diff --git a/packages/destination-actions/src/destinations/attio/identifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attio/identifyUser/__tests__/index.test.ts index 2f836023b8..0376a41b97 100644 --- a/packages/destination-actions/src/destinations/attio/identifyUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/attio/identifyUser/__tests__/index.test.ts @@ -12,7 +12,8 @@ const event = createTestEvent({ traits: { name: 'George Oscar Bluth', email - } + }, + receivedAt: '2024-05-24T10:00:00.000Z' }) const mapping = { @@ -22,6 +23,9 @@ const mapping = { name: { '@path': '$.traits.name' } + }, + received_at: { + '@path': '$.receivedAt' } } @@ -80,4 +84,70 @@ describe('Attio.identifyUser', () => { }) ).rejects.toThrowError() }) + + it('uses the batch assertion endpoint', async () => { + nock('https://api.attio.com') + .put('/v2/batch/records', { + assertions: [ + { + object: 'users', + mode: 'create-or-update', + matching_attribute: 'user_id', + multiselect_values: 'append', + values: { + primary_email_address: email, + user_id: event.userId, + name: event.traits?.name, + + person: { + object: 'people', + mode: 'create-or-update', + matching_attribute: 'email_addresses', + multiselect_values: 'append', + values: { + email_addresses: email + }, + received_at: '2024-05-24T10:00:00.000Z' + } + }, + received_at: '2024-05-24T10:00:00.000Z' + } + ] + }) + .reply(202, '') + + const responses = await testDestination.testBatchAction('identifyUser', { + events: [event], + mapping, + settings: {} + }) + + expect(responses.length).toBe(2) + expect(responses[1].status).toBe(202) + }) + + it('handles the case where receivedAt is not provided', async () => { + const lackingReceivedAtEvent = createTestEvent({ + type: 'identify' as const, + userId: '9', + traits: { + name: 'George Oscar Bluth', + email + }, + receivedAt: undefined + }) + + // Can't control the exact timestamp, so only check it starts on the same year-month-day and is ISO8601 formatted + const datePrefix = new Date().toISOString().split('T')[0] + + nock('https://api.attio.com') + .put('/v2/batch/records', new RegExp(`"received_at":"${datePrefix}T`)) + .reply(202, '') + + await testDestination.testBatchAction('identifyUser', { + events: [lackingReceivedAtEvent], + mapping, + settings: {} + }) + }) }) diff --git a/packages/destination-actions/src/destinations/attio/identifyUser/generated-types.ts b/packages/destination-actions/src/destinations/attio/identifyUser/generated-types.ts index e55da410eb..54f895f86b 100644 --- a/packages/destination-actions/src/destinations/attio/identifyUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/attio/identifyUser/generated-types.ts @@ -21,4 +21,16 @@ export interface Payload { person_attributes?: { [k: string]: unknown } + /** + * Send data to Attio in batches for much better performance. + */ + enable_batching?: boolean + /** + * Max batch size to send to Attio (limit is 10,000) + */ + batch_size?: number + /** + * When the event was received. + */ + received_at?: string | number } diff --git a/packages/destination-actions/src/destinations/attio/identifyUser/index.ts b/packages/destination-actions/src/destinations/attio/identifyUser/index.ts index ed13d7599e..683c8917b2 100644 --- a/packages/destination-actions/src/destinations/attio/identifyUser/index.ts +++ b/packages/destination-actions/src/destinations/attio/identifyUser/index.ts @@ -3,6 +3,7 @@ import type { InputField } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { AttioClient } from '../api' +import { commonFields } from '../common-fields' const email_address: InputField = { type: 'string', @@ -60,7 +61,8 @@ const action: ActionDefinition = { email_address, user_id, user_attributes, - person_attributes + person_attributes, + ...commonFields }, perform: async (request, { payload }) => { @@ -85,6 +87,37 @@ const action: ActionDefinition = { ...(payload.user_attributes ?? {}) } }) + }, + + performBatch: async (request, { payload }) => { + const client = new AttioClient(request) + + return await client.batchAssert({ + assertions: payload.map((item) => ({ + object: 'users', + mode: 'create-or-update', + matching_attribute: 'user_id', + multiselect_values: 'append', + values: { + primary_email_address: item.email_address, + user_id: item.user_id, + ...(item.user_attributes ?? {}), + + person: { + object: 'people', + mode: 'create-or-update', + matching_attribute: 'email_addresses', + multiselect_values: 'append', + values: { + email_addresses: item.email_address, + ...(item.person_attributes ?? {}) + }, + received_at: item.received_at?.toString() ?? new Date().toISOString() + } + }, + received_at: item.received_at?.toString() ?? new Date().toISOString() + })) + }) } }