Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Sendgrid Audiences] - adding user attribute and custom field support #2604

Merged
merged 7 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export const GET_CONTACT_BY_EMAIL_URL = 'https://api.sendgrid.com/v3/marketing/c

export const SEARCH_CONTACTS_URL = 'https://api.sendgrid.com/v3/marketing/contacts/search'

export const MAX_CHUNK_SIZE_SEARCH = 50
export const MAX_CHUNK_SIZE_SEARCH = 50

export const GET_CUSTOM_FIELDS_URL = 'https://api.sendgrid.com/v3/marketing/field_definitions'
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ const mapping = {
anonymous_id: { '@path': '$.anonymousId' },
external_id: { '@path': '$.traits.external_id' },
phone_number_id: { '@path': '$.traits.phone' },
user_attributes: {
first_name: { '@path': '$.traits.first_name' },
last_name: { '@path': '$.traits.last_name' },
address_line_1: { '@path': '$.traits.street' },
address_line_2: { '@path': '$.traits.address_line_2' },
city: { '@path': '$.traits.city' },
state_province_region: { '@path': '$.traits.state' },
country: { '@path': '$.traits.country' },
postal_code: { '@path': '$.traits.postal_code' }
},
custom_fields: { '@path': '$.traits.custom_fields' },
enable_batching: true,
batch_size: 200
}
Expand Down Expand Up @@ -97,6 +108,65 @@ describe('SendgridAudiences.syncAudience', () => {
expect(responses[0].status).toBe(200)
})

it('should upsert a single Contact with user attributes and custom fields, and add it to a Sendgrid list correctly', async () => {
const event = createTestEvent({
...addPayload,
traits: {
...addPayload.traits,
first_name: 'fname',
last_name: 'lname',
street: '123 Main St',
address_line_2: 123456, // should be stringified
city: 'SF',
state: 'CA',
country: 'US',
postal_code: "N88EU",
custom_fields: {
custom_field_1: 'custom_field_1_value',
custom_field_2: 2345,
custom_field_3: '2024-01-01T00:00:00.000Z',
custom_field_4: false, // should be removed
custom_field_5: null // should be removed
}
}
})

const addExpectedPayloadWithAttributes = {
...addExpectedPayload,
contacts: [
{
email: '[email protected]',
external_id: 'some_external_id',
phone_number_id: '+353123456789',
anonymous_id: 'some_anonymous_id',
first_name: 'fname',
last_name: 'lname',
address_line_1: '123 Main St',
address_line_2: '123456',
city: 'SF',
state_province_region: 'CA',
country: 'US',
postal_code: "N88EU",
custom_fields: {
custom_field_1: 'custom_field_1_value',
custom_field_2: 2345,
custom_field_3: '2024-01-01T00:00:00.000Z'
}
}
]
}

nock('https://api.sendgrid.com').put('/v3/marketing/contacts', addExpectedPayloadWithAttributes).reply(200, {})
const responses = await testDestination.testAction('syncAudience', {
event,
settings,
useDefaultMappings: true,
mapping
})
expect(responses.length).toBe(1)
expect(responses[0].status).toBe(200)
})

it('should upsert a single Contact with just email, and add it to a Sendgrid list correctly', async () => {
const addPayloadCopy = JSON.parse(JSON.stringify(addPayload))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@

import { RequestClient } from '@segment/actions-core'
import { DynamicFieldResponse } from '@segment/actions-core'
import { GET_CUSTOM_FIELDS_URL } from '../constants'
import { Payload } from './generated-types'

export async function dynamicCustomFields(request: RequestClient, payload: Payload): Promise<DynamicFieldResponse> {
interface ResultItem {
id: string
name: string
field_type: string
}
interface ResponseType {
data: {
custom_fields: Array<ResultItem>
}
}
interface ResultError {
response: {
data: {
errors: Array<{ message: string }>
},
status: number
}
}

try {
const response: ResponseType = await request(GET_CUSTOM_FIELDS_URL, {
method: 'GET',
skipResponseCloning: true
})

const allFields = response.data.custom_fields.map(field => field.name)
const selectedFields = new Set(Object.keys(payload.custom_fields ?? {}))
const availableFields = allFields.filter(field => !selectedFields.has(field))

return {
choices: availableFields.map(
fieldName => ({
label: fieldName,
value: fieldName
})
)
}

} catch (err) {
const error = err as ResultError
return {
choices: [],
error: {
message: error.response.data.errors.map((e) => e.message).join(';') ?? 'Unknown error: dynamicCustomFields',
code: String(error.response.status)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,123 @@ export const fields: Record<string, InputField> = {
}
}
},
user_attributes: {
label: 'User Attributes',
description: `Additional user attributes to be included in the request.`,
type: 'object',
required: false,
defaultObjectUI: 'keyvalue',
additionalProperties: false,
properties: {
first_name: {
label: 'First Name',
description: `The contact's first name.`,
type: 'string'
},
last_name: {
label: 'Last Name',
description: `The contact's last name.`,
type: 'string'
},
address_line_1: {
label: 'Address Line 1',
description: `The contact's address line 1.`,
type: 'string'
},
address_line_2: {
label: 'Address Line 2',
description: `The contact's address line 2.`,
type: 'string'
},
city: {
label: 'City',
description: `The contact's city.`,
type: 'string'
},
state_province_region: {
label: 'State/Province/Region',
description: `The contact's state, province, or region.`,
type: 'string'
},
country: {
label: 'Country',
description: `The contact's country.`,
type: 'string'
},
postal_code: {
label: 'Postal Code',
description: `The contact's postal code.`,
type: 'string'
}
},
default: {
first_name: {
'@if': {
exists: { '@path': '$.traits.first_name' },
then: { '@path': '$.traits.first_name' },
else: { '@path': '$.properties.first_name' }
}
},
last_name: {
'@if': {
exists: { '@path': '$.traits.last_name' },
then: { '@path': '$.traits.last_name' },
else: { '@path': '$.properties.last_name' }
}
},
address_line_1: {
'@if': {
exists: { '@path': '$.traits.street' },
then: { '@path': '$.traits.street' },
else: { '@path': '$.properties.street' }
}
},
address_line_2: {
'@if': {
exists: { '@path': '$.traits.address_line_2' },
then: { '@path': '$.traits.address_line_2' },
else: { '@path': '$.properties.address_line_2' }
}
},
city: {
'@if': {
exists: { '@path': '$.traits.city' },
then: { '@path': '$.traits.city' },
else: { '@path': '$.properties.city' }
}
},
state_province_region: {
'@if': {
exists: { '@path': '$.traits.state' },
then: { '@path': '$.traits.state' },
else: { '@path': '$.properties.state' }
}
},
country: {
'@if': {
exists: { '@path': '$.traits.country' },
then: { '@path': '$.traits.country' },
else: { '@path': '$.properties.country' }
}
},
postal_code: {
'@if': {
exists: { '@path': '$.traits.postal_code' },
then: { '@path': '$.traits.postal_code' },
else: { '@path': '$.properties.postal_code' }
}
}
}
},
custom_fields: {
label: 'Custom Fields',
description: `Custom Field values to be added to the Contact. The Custom Fields must already be defined in Sendgrid. Custom Field values must be string, numbers or dates.`,
type: 'object',
required: false,
defaultObjectUI: 'keyvalue',
additionalProperties: true,
dynamic: true
},
enable_batching: {
type: 'boolean',
label: 'Batch events',
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ import type { Settings } from '../generated-types'
import type { Payload } from './generated-types'
import { send } from './utils'
import { fields } from './fields'
import { dynamicCustomFields } from './dynamic-fields'

const action: ActionDefinition<Settings, Payload> = {
title: 'Sync Audience',
description: 'Sync a Segment Engage Audience to a Sendgrid List',
defaultSubscription: 'type = "identify" or type = "track"',
fields,
dynamicFields: {
custom_fields: {
__keys__: async (request, { payload }) => {
return await dynamicCustomFields(request, payload)
}
}
},
perform: async (request, { payload }) => {
return await send(request, [payload])
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,24 @@ function validate(payloads: Payload[], ignoreErrors: boolean, invalidEmails?: st
if (p.email && invalidEmails?.includes(p.email)) {
delete p.email
}

if (p.custom_fields) {
p.custom_fields = Object.fromEntries(
Object.entries(p.custom_fields).filter(([_, value]) => typeof value === 'string' || typeof value === 'number')
)
}

if (p.user_attributes) {
p.user_attributes = Object.fromEntries(
Object.entries(p.user_attributes ?? {}).map(([key, value]) => {
if (typeof value !== 'string') {
return [key, String(value)]
}
return [key, value]
})
);
}

const hasRequiredField = [p.email, p.anonymous_id, p.external_id, p.phone_number_id].some(Boolean)
if (!hasRequiredField && !ignoreErrors) {
throw new PayloadValidationError(
Expand All @@ -127,9 +145,14 @@ function createPayload(payloads: Payload[], externalAudienceId: string): UpsertC
email: payload.email ?? undefined,
phone_number_id: payload.phone_number_id ?? undefined,
external_id: payload.external_id ?? undefined,
anonymous_id: payload.anonymous_id ?? undefined
anonymous_id: payload.anonymous_id ?? undefined,
...payload.user_attributes,
custom_fields: payload.custom_fields && Object.keys(payload.custom_fields).length > 0
? payload.custom_fields
: undefined
})) as UpsertContactsReq['contacts']
}

return json
}

Expand Down
Loading
Loading