Skip to content

Commit

Permalink
[Sendgrid Audiences] - adding user attribute and custom field support (
Browse files Browse the repository at this point in the history
…segmentio#2604)

* adding user attribute support

* saving progress

* adding file

* adding support for custom_fields

* updating description for custom field

* coercing all user attributes to strings

* updating types
  • Loading branch information
joe-ayoub-segment authored Nov 26, 2024
1 parent 2d219c9 commit b31888c
Show file tree
Hide file tree
Showing 8 changed files with 332 additions and 2 deletions.
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

0 comments on commit b31888c

Please sign in to comment.