diff --git a/packages/destination-actions/src/destinations/batch/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/batch/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..3b126730a5 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-batch destination: trackEvent action - all fields 1`] = ` +Array [ + Object { + "events": Array [ + Object { + "attributes": Object { + "testType": "ek)J)lzyONWw(04s", + }, + "name": "ek)J)lzyONWw(04s", + }, + ], + "identifiers": Object { + "custom_id": "ek)J)lzyONWw(04s", + }, + }, +] +`; + +exports[`Testing snapshot for actions-batch destination: trackEvent action - required fields 1`] = ` +Array [ + Object { + "events": Array [], + "identifiers": Object { + "custom_id": "ek)J)lzyONWw(04s", + }, + }, +] +`; + +exports[`Testing snapshot for actions-batch destination: updateProfile action - all fields 1`] = ` +Array [ + Object { + "attributes": Object { + "$email_address": "E$Fjd*VczxvyBpVFPB", + "$email_marketing": "E$Fjd*VczxvyBpVFPB", + "$language": "E$Fjd*VczxvyBpVFPB", + "$phone_number": "E$Fjd*VczxvyBpVFPB", + "$region": "E$Fjd*VczxvyBpVFPB", + "$sms_marketing": "E$Fjd*VczxvyBpVFPB", + "$timezone": "E$Fjd*VczxvyBpVFPB", + "testType": "E$Fjd*VczxvyBpVFPB", + }, + "identifiers": Object { + "custom_id": "E$Fjd*VczxvyBpVFPB", + }, + }, +] +`; + +exports[`Testing snapshot for actions-batch destination: updateProfile action - required fields 1`] = ` +Array [ + Object { + "attributes": Object { + "$email_address": null, + "$email_marketing": null, + "$language": null, + "$phone_number": null, + "$region": null, + "$sms_marketing": null, + "$timezone": null, + }, + "identifiers": Object { + "custom_id": "E$Fjd*VczxvyBpVFPB", + }, + }, +] +`; diff --git a/packages/destination-actions/src/destinations/batch/__tests__/index.test.ts b/packages/destination-actions/src/destinations/batch/__tests__/index.test.ts new file mode 100644 index 0000000000..368ba17764 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/__tests__/index.test.ts @@ -0,0 +1,19 @@ +/*import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Batch', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock('https://your.destination.endpoint').get('*').reply(200, {}) + + // This should match your authentication.fields + const authData = {} + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + }) +}) +*/ diff --git a/packages/destination-actions/src/destinations/batch/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/batch/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..22882b01c5 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-batch' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/batch/generated-types.ts b/packages/destination-actions/src/destinations/batch/generated-types.ts new file mode 100644 index 0000000000..43f04da3f4 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Token used to authorize sending data to the Destination platform + */ + apiToken: string + /** + * The unique project key identifying your project in the Destination platform + */ + projectKey: string +} diff --git a/packages/destination-actions/src/destinations/batch/index.ts b/packages/destination-actions/src/destinations/batch/index.ts new file mode 100644 index 0000000000..f244122621 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/index.ts @@ -0,0 +1,64 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { DEFAULT_REQUEST_TIMEOUT } from '@segment/actions-core' + +import updateProfile from './updateProfile' +import trackEvent from './trackEvent' + +const destination: DestinationDefinition = { + name: 'Batch', + slug: 'actions-batch', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + apiToken: { + label: 'REST API Key', + description: 'Token used to authorize sending data to the Destination platform', + type: 'password', + required: true + }, + projectKey: { + label: 'Project Key', + description: 'The unique project key identifying your project in the Destination platform', + type: 'string', + required: true + } + }, + testAuthentication: async (request, { settings }) => { + // Check the authentification + const response = await request('https://api.batch.com/2.2/profiles/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${settings.apiToken}`, + 'X-Batch-Project': `${settings.projectKey}` + } + }) + + // If the response is a status code of 200, this means that authentication is valid + if (response.status !== 200) { + throw new Error('Invalid API token or project key') + } + } + }, + + extendRequest({ settings }) { + return { + headers: { + 'Content-Type': 'application/json', // Content Type + Authorization: `Bearer ${settings.apiToken}`, // REST API Key + 'X-Batch-Project': `${settings.projectKey}` // Project Key + }, + timeout: Math.max(30_000, DEFAULT_REQUEST_TIMEOUT) + } + }, + + actions: { + updateProfile, + trackEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/batch/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/batch/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..0d1b4b2a35 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Batch's trackEvent destination action: all fields 1`] = ` +Array [ + Object { + "events": Array [ + Object { + "attributes": Object {}, + "name": "8HZCkO#V", + }, + ], + "identifiers": Object { + "custom_id": "8HZCkO#V", + }, + }, +] +`; + +exports[`Testing snapshot for Batch's trackEvent destination action: required fields 1`] = ` +Array [ + Object { + "events": Array [], + "identifiers": Object { + "custom_id": "8HZCkO#V", + }, + }, +] +`; diff --git a/packages/destination-actions/src/destinations/batch/trackEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/batch/trackEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..94a2c2352d --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/trackEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'trackEvent' +const destinationSlug = 'Batch' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/batch/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/batch/trackEvent/generated-types.ts new file mode 100644 index 0000000000..ebf9962fc5 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/trackEvent/generated-types.ts @@ -0,0 +1,36 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Identifiant(s) de l'utilisateur + */ + identifiers?: { + /** + * The unique profile identifier + */ + custom_id: string + } + /** + * Profile event + */ + events?: { + /** + * The event's name + */ + name: string + /** + * The time an event occurred. It has to respect the RFC 3339 format. + */ + time?: string | number | null + /** + * An object containing all event's attributes + */ + attributes?: { + [k: string]: unknown + } | null + /** + * Maximum number of attributes to include in an event. + */ + event_attributes_batch_size?: number + }[] +} diff --git a/packages/destination-actions/src/destinations/batch/trackEvent/index.ts b/packages/destination-actions/src/destinations/batch/trackEvent/index.ts new file mode 100644 index 0000000000..d07c3f1875 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/trackEvent/index.ts @@ -0,0 +1,140 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Track Event', + description: '', + fields: { + identifiers: { + label: 'Identifiers', + description: "Identifiant(s) de l'utilisateur", + type: 'object', + properties: { + custom_id: { + label: 'User ID', + description: 'The unique profile identifier', + type: 'string', + required: true, + default: { + '@path': '$.userId' + } + } + } + }, + events: { + label: 'Event', + description: 'Profile event', + type: 'object', + required: false, + multiple: true, + properties: { + name: { + label: 'Name', + description: "The event's name", + type: 'string', + required: true, + default: { + '@path': '$.event' + } + }, + time: { + label: 'Time', + description: 'The time an event occurred. It has to respect the RFC 3339 format.', + type: 'datetime', + allowNull: true, + default: { + '@path': '$.receivedAt' + } + }, + attributes: { + label: 'attributes', + description: "An object containing all event's attributes", + type: 'object', + allowNull: true, + default: { + '@path': '$.properties' + } + }, + event_attributes_batch_size: { + label: 'Event Attribute Batch Size', + description: 'Maximum number of attributes to include in an event.', + type: 'number', + default: 15, + unsafe_hidden: true + } + } + } + }, + perform: (request, data) => { + const newPayload = buildProfileJsonWithEvents(data.payload) + + return request('https://api.batch.com/2.5/profiles/update', { + method: 'post', + json: newPayload + }) + } +} + +function buildProfileJsonWithEvents(data: Payload) { + // Browse events and obtain the batch size for each event + const events = (data.events || []).map((event: any) => { + // Retrieve the batch size for this event (default 15 if not specified) + const eventAttributesBatchSize = event.event_attributes_batch_size || 15 + + // Retrieve event attributes + const eventAttributes = event.attributes || {} + + // Limit event attributes according to batch size + const limitedEventAttributes = Object.keys(eventAttributes) + .slice(0, eventAttributesBatchSize) // Limite la taille à 'batchSize' + .reduce((obj: Record, key: string) => { + const value = eventAttributes[key] + // Check if the value is an ISO 8601 date and add 'date()' prefix to the key + if (isISO8601Date(value as string)) { + obj[`date(${key})`] = value + } + // Check if the value is a valid URL and add 'url()' prefix to the key + else if (isValidUrl(value as string)) { + obj[`url(${key})`] = value + } else { + obj[key] = value + } + + return obj + }, {}) + + // Return the event with its limited attributes + return { + name: event.name, + attributes: limitedEventAttributes + } + }) + + // Recovery of identifiers + const identifiers = { + custom_id: data.identifiers?.custom_id || '' // Assurer que custom_id soit une chaîne non nulle + } + + // Return the final JSON with identifiers, attributes and events + return [ + { + identifiers: identifiers, + events: events + } + ] +} + +// Utility function to check if a string is in ISO 8601 date format +function isISO8601Date(value: string): boolean { + const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3,6})?Z$/ + return typeof value === 'string' && iso8601Regex.test(value) +} + +// Utility function to check if a string is a valid URL +function isValidUrl(value: string): boolean { + const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i + return typeof value === 'string' && urlRegex.test(value) +} + +export default action diff --git a/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..22bc7f55dd --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Batch's updateProfile destination action: all fields 1`] = ` +Array [ + Object { + "attributes": Object { + "$email_address": "*yCKAwocn#", + "$email_marketing": "*yCKAwocn#", + "$language": "*yCKAwocn#", + "$phone_number": "*yCKAwocn#", + "$region": "*yCKAwocn#", + "$sms_marketing": "*yCKAwocn#", + "$timezone": "*yCKAwocn#", + }, + "identifiers": Object { + "custom_id": "*yCKAwocn#", + }, + }, +] +`; + +exports[`Testing snapshot for Batch's updateProfile destination action: required fields 1`] = ` +Array [ + Object { + "attributes": Object { + "$email_address": null, + "$email_marketing": null, + "$language": null, + "$phone_number": null, + "$region": null, + "$sms_marketing": null, + "$timezone": null, + }, + "identifiers": Object { + "custom_id": "*yCKAwocn#", + }, + }, +] +`; diff --git a/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/index.test.ts b/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/index.test.ts new file mode 100644 index 0000000000..960dac41b2 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/index.test.ts @@ -0,0 +1,65 @@ +// import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const settings = { + apiToken: '', // = REST API Key + projectKey: '', + endpoint: 'https://api.batch.com/2.3/profiles/update' as const +} + +const testDestination = createTestIntegration(Destination) + +describe('Batch.updateProfile', () => { + // TODO: Test my action + it('should process required fields correctly', async () => { + const action = Destination.actions.updateProfile + + const eventData = { + receivedAt: '2025-01-02T14:18:45.187Z', + timestamp: '2025-01-02T14:18:42.235Z', + properties: { + id: 39792, + email: 'antoine39792@hotmail.com', + firstName: 'Test', + lastName: 'User', + birthday: '2024-06-06T18:13:48+02:00' + }, + context: { + library: { + name: 'unknown', + version: 'unknown' + } + }, + type: 'identify', + userId: '8de68ddc-22ab-4c1e-a50b-dd6f3a63da06', + originalTimestamp: '2025-01-02T14:18:42.235Z', + messageId: 'api-2r4o5eBJElExhnmTMqEg3OAEL7H', + integrations: {} + } + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(action.slug, { + event: event, + mapping: event.properties, + settings: settings, + auth: undefined + }) + console.log('testAction : ' + testDestination.testAction) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() // Comparaison avec un snapshot + } catch (err) { + expect(rawBody).toMatchSnapshot() // Si erreur dans la conversion JSON, vérifier le rawBody + } + + expect(request.headers).toMatchSnapshot() // Comparer les en-têtes de la requête + }) +}) diff --git a/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..d7cf9491ad --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/updateProfile/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'updateProfile' +const destinationSlug = 'Batch' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/batch/updateProfile/generated-types.ts b/packages/destination-actions/src/destinations/batch/updateProfile/generated-types.ts new file mode 100644 index 0000000000..3a55164bd4 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/updateProfile/generated-types.ts @@ -0,0 +1,56 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Identifiant(s) de l'utilisateur + */ + identifiers?: { + /** + * The unique profile identifier + */ + custom_id: string + } + /** + * Profile data + */ + attributes?: { + /** + * The profile's email + */ + email_address?: string | null + /** + * The profile's marketing emails subscription. You can set it to subscribed , unsubscribed , or null to reset the marketing emails subscription. + */ + email_marketing?: string | null + /** + * The profile's phone number + */ + phone_number?: string | null + /** + * The profile's marketing SMS subscription. You can set it to subscribed , unsubscribed , or null to reset the marketing SMS subscription. + */ + sms_marketing?: string | null + /** + * The profile's language. + */ + language?: string | null + /** + * The profile's region + */ + region?: string | null + /** + * The profile’s time zone name from IANA Time Zone Database (e.g., “Europe/Paris”). Only valid time zone values will be set. + */ + timezone?: string | null + /** + * The profile’s custom attributes + */ + properties?: { + [k: string]: unknown + } + /** + * Maximum number of attributes to include in each batch. + */ + batch_size?: number + } +} diff --git a/packages/destination-actions/src/destinations/batch/updateProfile/index.ts b/packages/destination-actions/src/destinations/batch/updateProfile/index.ts new file mode 100644 index 0000000000..e7d8a349c4 --- /dev/null +++ b/packages/destination-actions/src/destinations/batch/updateProfile/index.ts @@ -0,0 +1,186 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Update User Profile', + description: 'Update or create a profile with attributes in Batch', + fields: { + identifiers: { + label: 'Identifiers', + description: "Identifiant(s) de l'utilisateur", + type: 'object', + properties: { + custom_id: { + label: 'User ID', + description: 'The unique profile identifier', + type: 'string', + required: true, + default: { + '@path': '$.userId' + } + } + } + }, + attributes: { + label: 'Attributes', + description: 'Profile data', + type: 'object', + properties: { + email_address: { + label: 'Email', + description: "The profile's email", + type: 'string', + allowNull: true, + default: { + '@path': '$.traits.email' + } + }, + email_marketing: { + label: 'Email marketing subscribe', + description: + "The profile's marketing emails subscription. You can set it to subscribed , unsubscribed , or null to reset the marketing emails subscription.", + type: 'string', + allowNull: true + }, + phone_number: { + label: 'Phone Number', + description: "The profile's phone number", + type: 'string', + allowNull: true, + default: { + '@path': '$.traits.phone_number' + } + }, + sms_marketing: { + label: 'SMS marketing subscribe', + description: + "The profile's marketing SMS subscription. You can set it to subscribed , unsubscribed , or null to reset the marketing SMS subscription.", + type: 'string', + allowNull: true + }, + language: { + label: 'Language', + description: "The profile's language.", + type: 'string', + allowNull: true, + default: { + '@path': '$.traits.language' + } + }, + region: { + label: 'Region', + description: "The profile's region", + type: 'string', + allowNull: true, + default: { + '@path': '$.context.location.country' + } + }, + timezone: { + label: 'Timezone', + description: + 'The profile’s time zone name from IANA Time Zone Database (e.g., “Europe/Paris”). Only valid time zone values will be set.', + type: 'string', + allowNull: true, + default: { + '@path': '$.context.timezone' + } + }, + properties: { + label: 'Custom attributes', + description: 'The profile’s custom attributes ', + type: 'object', + default: { + '@path': '$.properties' + } + }, + batch_size: { + label: 'Batch Size', + description: 'Maximum number of attributes to include in each batch.', + type: 'number', + default: 50, + unsafe_hidden: true + } + } + } + }, + perform: (request, data) => { + const newPayload = buildProfileJson(data.payload) + + return request('https://api.batch.com/2.5/profiles/update', { + method: 'post', + json: newPayload + }) + } +} + +function buildProfileJson(data: Payload): Payload[] { + // Retrieve the batch size dynamically or default to 50 + const batchSize = data.attributes?.batch_size || 50 + + // Extract identifiers + const identifiers = { + custom_id: data.identifiers?.custom_id || '' // Unique identifier + } + + // Extract standard attributes + const attributes = { + $email_address: data.attributes?.email_address || null, + $email_marketing: data.attributes?.email_marketing || null, + $phone_number: data.attributes?.phone_number || null, + $sms_marketing: data.attributes?.sms_marketing || null, + $language: data.attributes?.language || null, + $region: data.attributes?.region || null, + $timezone: data.attributes?.timezone || null + } + + // Extract custom properties with batch size limitation + const customProperties = data.attributes?.properties || {} + Object.keys(attributes).forEach((key) => { + delete customProperties[key] + }) + + const limitedProperties = Object.keys(customProperties) + .slice(0, batchSize) // Limit the size to batchSize + .reduce((obj: Record, key: string) => { + const value = customProperties[key] + // Check if the value is an ISO 8601 date and add 'date()' prefix to the key + if (isISO8601Date(value as string)) { + obj[`date(${key})`] = value + } + // Check if the value is a valid URL and add 'url()' prefix to the key + else if (isValidUrl(value as string)) { + obj[`url(${key})`] = value + } else { + obj[key] = value + } + + return obj + }, {}) + + // Merge standard attributes and custom properties + const fullAttributes = { ...attributes, ...limitedProperties } + + // Wrap the output in an array + return [ + { + identifiers: identifiers, + attributes: fullAttributes + } + ] +} + +// Utility function to check if a string is in ISO 8601 date format +function isISO8601Date(value: string): boolean { + const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3,6})?Z$/ + return typeof value === 'string' && iso8601Regex.test(value) +} + +// Utility function to check if a string is a valid URL +function isValidUrl(value: string): boolean { + const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i + return typeof value === 'string' && urlRegex.test(value) +} + +export default action