From 5362ee873d4e52d9f8c9e190627c593cd538d3e2 Mon Sep 17 00:00:00 2001 From: toddtarsi Date: Thu, 9 Jul 2026 15:26:24 -0500 Subject: [PATCH 1/3] Add VoiceOps name mapping fields --- .../__snapshots__/snapshot.test.ts.snap | 7 +- .../__snapshots__/snapshot.test.ts.snap | 7 +- .../sendCallCompleted/__tests__/index.test.ts | 66 +++++++++++++- .../sendCallCompleted/generated-types.ts | 20 ++++- .../voiceops/sendCallCompleted/index.ts | 89 ++++++++++++++++--- 5 files changed, 165 insertions(+), 24 deletions(-) diff --git a/packages/destination-actions/src/destinations/voiceops/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/voiceops/__tests__/__snapshots__/snapshot.test.ts.snap index 240d8113a4..19c6f08287 100644 --- a/packages/destination-actions/src/destinations/voiceops/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/voiceops/__tests__/__snapshots__/snapshot.test.ts.snap @@ -3,6 +3,9 @@ exports[`Testing snapshot for actions-voiceops destination: sendCallCompleted action - all fields 1`] = ` Object { "agent_email": "bah@curoj.cw", + "agent_first_name": "PdayLkf!", + "agent_last_name": "PdayLkf!", + "assign_first_last_name_by_splitting_email": true, "call_id": "PdayLkf!", "call_started_at": Any, "channels": Array [ @@ -14,11 +17,11 @@ Object { "type": "CONTACT", }, ], + "customer_first_name": "PdayLkf!", + "customer_last_name": "PdayLkf!", "extraMetadata": Object { "testType": "PdayLkf!", }, - "first_name": "PdayLkf!", - "last_name": "PdayLkf!", "recording_url": "https://example.com/audio.wav", } `; diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/__snapshots__/snapshot.test.ts.snap index d6e8d968d2..0039ce64a0 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/__snapshots__/snapshot.test.ts.snap @@ -3,6 +3,9 @@ exports[`Testing snapshot for actions-voiceops's sendCallCompleted destination action: all fields 1`] = ` Object { "agent_email": "bah@curoj.cw", + "agent_first_name": "PdayLkf!", + "agent_last_name": "PdayLkf!", + "assign_first_last_name_by_splitting_email": true, "call_id": "PdayLkf!", "call_started_at": Any, "channels": Array [ @@ -14,11 +17,11 @@ Object { "type": "CONTACT", }, ], + "customer_first_name": "PdayLkf!", + "customer_last_name": "PdayLkf!", "extraMetadata": Object { "testType": "PdayLkf!", }, - "first_name": "PdayLkf!", - "last_name": "PdayLkf!", "recording_url": "https://example.com/audio.wav", } `; diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts index 68b6c30e5f..f296a8d91f 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts @@ -64,6 +64,64 @@ describe('Voiceops.sendCallCompleted', () => { expect(responses[0].options.json).not.toHaveProperty('agentLegs') }) + it('forwards customer and agent names unchanged', async () => { + nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) + + const event = createCallCompletedEvent({ + customer_first_name: 'Casey', + customer_last_name: 'Customer', + agent_first_name: 'Ava', + agent_last_name: 'Agent' + }) + + const responses = await testDestination.testAction('sendCallCompleted', { + event, + settings: SETTINGS, + useDefaultMappings: true + }) + + expect(responses[0].options.json).toMatchObject({ + customer_first_name: 'Casey', + customer_last_name: 'Customer', + agent_first_name: 'Ava', + agent_last_name: 'Agent' + }) + }) + + it('derives missing agent names from email when enabled', async () => { + nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) + + const event = createCallCompletedEvent({ + agent_email: 'ava-agent@voiceops.com', + assign_first_last_name_by_splitting_email: true, + agentLegs: [ + { + agent_email: 'first-agent@voiceops.com', + started_at: '2025-12-08T13:32:47.000Z' + } + ] + }) + + const responses = await testDestination.testAction('sendCallCompleted', { + event, + settings: SETTINGS, + useDefaultMappings: true + }) + + expect(responses[0].options.json).toMatchObject({ + agent_first_name: 'Ava', + agent_last_name: 'Agent', + assign_first_last_name_by_splitting_email: true, + agentLegs: [ + { + agent_email: 'first-agent@voiceops.com', + first_name: 'First', + last_name: 'Agent' + } + ] + }) + }) + it('forwards channels with a supported type unchanged', async () => { nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) @@ -203,10 +261,10 @@ describe('Voiceops.sendCallCompleted', () => { ).rejects.toThrow() }) - it('still succeeds when first_name and last_name are omitted', async () => { + it('still succeeds when agent_first_name and agent_last_name are omitted', async () => { nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) - const event = createCallCompletedEvent({ first_name: undefined, last_name: undefined }) + const event = createCallCompletedEvent({ agent_first_name: undefined, agent_last_name: undefined }) const responses = await testDestination.testAction('sendCallCompleted', { event, @@ -219,8 +277,8 @@ describe('Voiceops.sendCallCompleted', () => { call_id: 'call-123', agent_email: 'agent@voiceops.com' }) - expect(responses[0].options.json).not.toHaveProperty('first_name') - expect(responses[0].options.json).not.toHaveProperty('last_name') + expect(responses[0].options.json).not.toHaveProperty('agent_first_name') + expect(responses[0].options.json).not.toHaveProperty('agent_last_name') }) it('still succeeds when neither channels nor agentLegs are provided', async () => { diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/generated-types.ts b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/generated-types.ts index 1e6ac3d68d..9e98589062 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/generated-types.ts +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/generated-types.ts @@ -17,14 +17,26 @@ export interface Payload { * A direct URI to the call recording file, for example `https://example.com/audio.wav`. */ recording_url: string + /** + * The first name for the customer. + */ + customer_first_name?: string + /** + * The last name for the customer. + */ + customer_last_name?: string /** * The first name for the primary handling agent. */ - first_name?: string + agent_first_name?: string /** * The last name for the primary handling agent. */ - last_name?: string + agent_last_name?: string + /** + * When enabled, missing agent first and last names are derived from agent email addresses by splitting the email local-part. + */ + assign_first_last_name_by_splitting_email?: boolean /** * Optional channel metadata for multi-channel audio-aware integrations. Use this when you can provide precise channel-based conference bridge data. */ @@ -73,11 +85,11 @@ export interface Payload { /** * The first name of the agent for this call leg. */ - first_name: string + first_name?: string /** * The last name of the agent for this call leg. */ - last_name: string + last_name?: string }[] /** * Additional call metadata to forward to Voiceops unchanged. diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts index 85c65c09ac..d9e7bc629e 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts @@ -8,6 +8,47 @@ const HANDLING_AGENT_TYPE = 'HANDLING_AGENT' const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const UNIX_SECONDS_PATTERN = /^\d{10}$/ +function titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase() +} + +function splitNameFromEmail(email?: string): { first_name?: string; last_name?: string } { + const localPart = email?.split('@')[0]?.split('+')[0] + const nameParts = localPart?.split(/[^a-zA-Z0-9]+/).filter(Boolean) ?? [] + + if (nameParts.length === 0) { + return {} + } + + return { + first_name: titleCase(nameParts[0]), + last_name: nameParts.length > 1 ? nameParts.slice(1).map(titleCase).join(' ') : undefined + } +} + +function withEmailSplitNames(payload: Payload): Payload { + if (!payload.assign_first_last_name_by_splitting_email) { + return payload + } + + const primaryAgentName = splitNameFromEmail(payload.agent_email) + + return { + ...payload, + agent_first_name: payload.agent_first_name || primaryAgentName.first_name, + agent_last_name: payload.agent_last_name || primaryAgentName.last_name, + agentLegs: payload.agentLegs?.map((agentLeg) => { + const splitName = splitNameFromEmail(agentLeg.agent_email) + + return { + ...agentLeg, + first_name: agentLeg.first_name || splitName.first_name, + last_name: agentLeg.last_name || splitName.last_name + } + }) + } +} + function validateSegmentPayload(payload: Payload): void { if (!UNIX_SECONDS_PATTERN.test(payload.call_started_at)) { throw new PayloadValidationError('call_started_at must be a 10-digit Unix timestamp in seconds.') @@ -86,20 +127,45 @@ const action: ActionDefinition = { '@path': '$.properties.recording_url' } }, - first_name: { - label: 'First Name', + customer_first_name: { + label: 'Customer First Name', + description: 'The first name for the customer.', + type: 'string', + default: { + '@path': '$.properties.customer_first_name' + } + }, + customer_last_name: { + label: 'Customer Last Name', + description: 'The last name for the customer.', + type: 'string', + default: { + '@path': '$.properties.customer_last_name' + } + }, + agent_first_name: { + label: 'Agent First Name', description: 'The first name for the primary handling agent.', type: 'string', default: { - '@path': '$.properties.first_name' + '@path': '$.properties.agent_first_name' } }, - last_name: { - label: 'Last Name', + agent_last_name: { + label: 'Agent Last Name', description: 'The last name for the primary handling agent.', type: 'string', default: { - '@path': '$.properties.last_name' + '@path': '$.properties.agent_last_name' + } + }, + assign_first_last_name_by_splitting_email: { + label: 'Assign First / Last Name By Splitting Email', + description: + 'When enabled, missing agent first and last names are derived from agent email addresses by splitting the email local-part.', + type: 'boolean', + default: { + '@path': '$.properties.assign_first_last_name_by_splitting_email' } }, channels: { @@ -213,14 +279,12 @@ const action: ActionDefinition = { first_name: { label: 'First Name', description: 'The first name of the agent for this call leg.', - type: 'string', - required: true + type: 'string' }, last_name: { label: 'Last Name', description: 'The last name of the agent for this call leg.', - type: 'string', - required: true + type: 'string' } }, default: { @@ -257,11 +321,12 @@ const action: ActionDefinition = { } }, perform: (request, data) => { - validateSegmentPayload(data.payload) + const payload = withEmailSplitNames(data.payload) + validateSegmentPayload(payload) return request(getVoiceopsCallsEndpoint(data.settings.baseUrl), { method: 'post', - json: data.payload + json: payload }) } } From 17cc1a92555fb0b46934c429ca0f0a8dcf2852e1 Mon Sep 17 00:00:00 2001 From: toddtarsi Date: Fri, 10 Jul 2026 07:00:08 -0500 Subject: [PATCH 2/3] Address VoiceOps name split feedback --- .../sendCallCompleted/__tests__/index.test.ts | 41 ++++++++++++++++++- .../voiceops/sendCallCompleted/index.ts | 22 ++++++---- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts index f296a8d91f..4c399d5674 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts @@ -122,6 +122,39 @@ describe('Voiceops.sendCallCompleted', () => { }) }) + it('derives an empty last name from single-token email local-parts when enabled', async () => { + nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) + + const event = createCallCompletedEvent({ + agent_email: 'ava@voiceops.com', + assign_first_last_name_by_splitting_email: true, + agentLegs: [ + { + agent_email: 'ava@voiceops.com', + started_at: '2025-12-08T13:32:47.000Z' + } + ] + }) + + const responses = await testDestination.testAction('sendCallCompleted', { + event, + settings: SETTINGS, + useDefaultMappings: true + }) + + expect(responses[0].options.json).toMatchObject({ + agent_first_name: 'Ava', + agent_last_name: '', + agentLegs: [ + { + agent_email: 'ava@voiceops.com', + first_name: 'Ava', + last_name: '' + } + ] + }) + }) + it('forwards channels with a supported type unchanged', async () => { nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) @@ -411,7 +444,9 @@ describe('Voiceops.sendCallCompleted', () => { settings: SETTINGS, useDefaultMappings: true }) - ).rejects.toThrow() + ).rejects.toThrow( + 'agentLegs.first_name is required for every agent leg entry. Provide first_name and last_name, or enable assign_first_last_name_by_splitting_email and provide an agent_email that can be split.' + ) }) it('fails when an agent leg is missing last_name', async () => { @@ -431,7 +466,9 @@ describe('Voiceops.sendCallCompleted', () => { settings: SETTINGS, useDefaultMappings: true }) - ).rejects.toThrow() + ).rejects.toThrow( + 'agentLegs.last_name is required for every agent leg entry. Provide first_name and last_name, or enable assign_first_last_name_by_splitting_email and provide an agent_email that can be split. Single-token email local-parts derive an empty last_name.' + ) }) it('fails when a HANDLING_AGENT channel identifier is not an email address', async () => { diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts index d9e7bc629e..b307dc706e 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts @@ -22,7 +22,7 @@ function splitNameFromEmail(email?: string): { first_name?: string; last_name?: return { first_name: titleCase(nameParts[0]), - last_name: nameParts.length > 1 ? nameParts.slice(1).map(titleCase).join(' ') : undefined + last_name: nameParts.length > 1 ? nameParts.slice(1).map(titleCase).join(' ') : '' } } @@ -75,11 +75,15 @@ function validateSegmentPayload(payload: Payload): void { for (const agentLeg of payload.agentLegs ?? []) { if (!agentLeg.first_name?.trim()) { - throw new PayloadValidationError('agentLegs.first_name is required for every agent leg entry.') + throw new PayloadValidationError( + 'agentLegs.first_name is required for every agent leg entry. Provide first_name and last_name, or enable assign_first_last_name_by_splitting_email and provide an agent_email that can be split.' + ) } - if (!agentLeg.last_name?.trim()) { - throw new PayloadValidationError('agentLegs.last_name is required for every agent leg entry.') + if (agentLeg.last_name === undefined || agentLeg.last_name === null) { + throw new PayloadValidationError( + 'agentLegs.last_name is required for every agent leg entry. Provide first_name and last_name, or enable assign_first_last_name_by_splitting_email and provide an agent_email that can be split. Single-token email local-parts derive an empty last_name.' + ) } } } @@ -162,7 +166,7 @@ const action: ActionDefinition = { assign_first_last_name_by_splitting_email: { label: 'Assign First / Last Name By Splitting Email', description: - 'When enabled, missing agent first and last names are derived from agent email addresses by splitting the email local-part.', + 'When enabled, missing agent first and last names are derived from agent email addresses by splitting the email local-part. Single-token local-parts derive the first name and set last name to an empty string.', type: 'boolean', default: { '@path': '$.properties.assign_first_last_name_by_splitting_email' @@ -249,7 +253,7 @@ const action: ActionDefinition = { agentLegs: { label: 'Agent Legs', description: - 'Optional warm-transfer metadata for agent handoff windows. Use this when you cannot provide channel-based multi-channel recording data.', + 'Optional warm-transfer metadata for agent handoff windows. Use this when you cannot provide channel-based multi-channel recording data. Each leg must include first and last name, or enable Assign First / Last Name By Splitting Email and provide a splittable agent email.', type: 'object', multiple: true, defaultObjectUI: 'arrayeditor', @@ -278,12 +282,14 @@ const action: ActionDefinition = { }, first_name: { label: 'First Name', - description: 'The first name of the agent for this call leg.', + description: + 'The first name of the agent for this call leg. Required unless Assign First / Last Name By Splitting Email derives it from agent email.', type: 'string' }, last_name: { label: 'Last Name', - description: 'The last name of the agent for this call leg.', + description: + 'The last name of the agent for this call leg. Required unless Assign First / Last Name By Splitting Email derives it from agent email; single-token email local-parts derive an empty last name.', type: 'string' } }, From 9afda046929153750dfdce4bc27eab3ecf965e8b Mon Sep 17 00:00:00 2001 From: toddtarsi Date: Tue, 21 Jul 2026 14:37:52 -0500 Subject: [PATCH 3/3] Preserve VoiceOps agent name mapping keys --- .../__snapshots__/snapshot.test.ts.snap | 2 + .../src/destinations/voiceops/metadata.json | 163 +++++++++++++++++- .../__snapshots__/snapshot.test.ts.snap | 2 + .../sendCallCompleted/__tests__/index.test.ts | 116 ++++++++++++- .../sendCallCompleted/generated-types.ts | 20 ++- .../voiceops/sendCallCompleted/index.ts | 52 +++++- 6 files changed, 329 insertions(+), 26 deletions(-) diff --git a/packages/destination-actions/src/destinations/voiceops/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/voiceops/__tests__/__snapshots__/snapshot.test.ts.snap index 19c6f08287..6124a75543 100644 --- a/packages/destination-actions/src/destinations/voiceops/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/voiceops/__tests__/__snapshots__/snapshot.test.ts.snap @@ -22,6 +22,8 @@ Object { "extraMetadata": Object { "testType": "PdayLkf!", }, + "first_name": "PdayLkf!", + "last_name": "PdayLkf!", "recording_url": "https://example.com/audio.wav", } `; diff --git a/packages/destination-actions/src/destinations/voiceops/metadata.json b/packages/destination-actions/src/destinations/voiceops/metadata.json index 8dc177809d..124411d134 100644 --- a/packages/destination-actions/src/destinations/voiceops/metadata.json +++ b/packages/destination-actions/src/destinations/voiceops/metadata.json @@ -145,9 +145,61 @@ "format": "uri", "additionalProperties": false }, + "customer_first_name": { + "label": "Customer First Name", + "description": "The first name for the customer. Use this field for new mappings.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.properties.customer_first_name" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "customer_last_name": { + "label": "Customer Last Name", + "description": "The last name for the customer. Use this field for new mappings.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.properties.customer_last_name" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, "first_name": { - "label": "First Name", - "description": "The first name for the primary handling agent.", + "label": "Customer First Name (Legacy)", + "description": "Deprecated legacy first name field currently used for customer names. For new mappings, use Customer First Name.", "type": "string", "required": false, "multiple": false, @@ -172,8 +224,8 @@ "additionalProperties": false }, "last_name": { - "label": "Last Name", - "description": "The last name for the primary handling agent.", + "label": "Customer Last Name (Legacy)", + "description": "Deprecated legacy last name field currently used for customer names. For new mappings, use Customer Last Name.", "type": "string", "required": false, "multiple": false, @@ -197,6 +249,84 @@ "format": null, "additionalProperties": false }, + "agent_first_name": { + "label": "Agent First Name", + "description": "The first name for the primary handling agent.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.properties.agent_first_name" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "agent_last_name": { + "label": "Agent Last Name", + "description": "The last name for the primary handling agent.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.properties.agent_last_name" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "assign_first_last_name_by_splitting_email": { + "label": "Assign First / Last Name By Splitting Email", + "description": "When enabled, missing agent first and last names are derived from agent email addresses by splitting the email local-part. Single-token local-parts derive the first name and set last name to an empty string.", + "type": "boolean", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.properties.assign_first_last_name_by_splitting_email" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, "channels": { "label": "Channels", "description": "Optional channel metadata for multi-channel audio-aware integrations. Use this when you can provide precise channel-based conference bridge data.", @@ -405,7 +535,7 @@ }, "agentLegs": { "label": "Agent Legs", - "description": "Optional warm-transfer metadata for agent handoff windows. Use this when you cannot provide channel-based multi-channel recording data.", + "description": "Optional warm-transfer metadata for agent handoff windows. Use this when you cannot provide channel-based multi-channel recording data. Each leg must include first and last name, or enable Assign First / Last Name By Splitting Email and provide a splittable agent email.", "type": "object", "required": false, "multiple": true, @@ -510,9 +640,9 @@ }, "first_name": { "label": "First Name", - "description": "The first name of the agent for this call leg.", + "description": "The first name of the agent for this call leg. Required unless Assign First / Last Name By Splitting Email derives it from agent email.", "type": "string", - "required": true, + "required": false, "multiple": false, "allowNull": false, "dynamic": false, @@ -534,9 +664,9 @@ }, "last_name": { "label": "Last Name", - "description": "The last name of the agent for this call leg.", + "description": "The last name of the agent for this call leg. Required unless Assign First / Last Name By Splitting Email derives it from agent email; single-token email local-parts derive an empty last name.", "type": "string", - "required": true, + "required": false, "multiple": false, "allowNull": false, "dynamic": false, @@ -617,12 +747,27 @@ "recording_url": { "@path": "$.properties.recording_url" }, + "customer_first_name": { + "@path": "$.properties.customer_first_name" + }, + "customer_last_name": { + "@path": "$.properties.customer_last_name" + }, "first_name": { "@path": "$.properties.first_name" }, "last_name": { "@path": "$.properties.last_name" }, + "agent_first_name": { + "@path": "$.properties.agent_first_name" + }, + "agent_last_name": { + "@path": "$.properties.agent_last_name" + }, + "assign_first_last_name_by_splitting_email": { + "@path": "$.properties.assign_first_last_name_by_splitting_email" + }, "channels": { "@arrayPath": [ "$.properties.channels", diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/__snapshots__/snapshot.test.ts.snap index 0039ce64a0..a39d62da3c 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/__snapshots__/snapshot.test.ts.snap @@ -22,6 +22,8 @@ Object { "extraMetadata": Object { "testType": "PdayLkf!", }, + "first_name": "PdayLkf!", + "last_name": "PdayLkf!", "recording_url": "https://example.com/audio.wav", } `; diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts index 4c399d5674..1f7b384699 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/__tests__/index.test.ts @@ -70,6 +70,8 @@ describe('Voiceops.sendCallCompleted', () => { const event = createCallCompletedEvent({ customer_first_name: 'Casey', customer_last_name: 'Customer', + first_name: 'Legacy', + last_name: 'Customer', agent_first_name: 'Ava', agent_last_name: 'Agent' }) @@ -83,16 +85,20 @@ describe('Voiceops.sendCallCompleted', () => { expect(responses[0].options.json).toMatchObject({ customer_first_name: 'Casey', customer_last_name: 'Customer', + first_name: 'Legacy', + last_name: 'Customer', agent_first_name: 'Ava', agent_last_name: 'Agent' }) }) - it('derives missing agent names from email when enabled', async () => { + it('derives missing agent names from email without overwriting legacy customer names when enabled', async () => { nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) const event = createCallCompletedEvent({ agent_email: 'ava-agent@voiceops.com', + first_name: 'Legacy', + last_name: 'Customer', assign_first_last_name_by_splitting_email: true, agentLegs: [ { @@ -109,6 +115,8 @@ describe('Voiceops.sendCallCompleted', () => { }) expect(responses[0].options.json).toMatchObject({ + first_name: 'Legacy', + last_name: 'Customer', agent_first_name: 'Ava', agent_last_name: 'Agent', assign_first_last_name_by_splitting_email: true, @@ -155,6 +163,38 @@ describe('Voiceops.sendCallCompleted', () => { }) }) + it('preserves derived token casing after the first character', async () => { + nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) + + const event = createCallCompletedEvent({ + agent_email: 'ava-McDonald@voiceops.com', + assign_first_last_name_by_splitting_email: true, + agentLegs: [ + { + agent_email: 'ava-McDonald@voiceops.com', + started_at: '2025-12-08T13:32:47.000Z' + } + ] + }) + + const responses = await testDestination.testAction('sendCallCompleted', { + event, + settings: SETTINGS, + useDefaultMappings: true + }) + + expect(responses[0].options.json).toMatchObject({ + agent_first_name: 'Ava', + agent_last_name: 'McDonald', + agentLegs: [ + { + first_name: 'Ava', + last_name: 'McDonald' + } + ] + }) + }) + it('forwards channels with a supported type unchanged', async () => { nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) @@ -294,10 +334,10 @@ describe('Voiceops.sendCallCompleted', () => { ).rejects.toThrow() }) - it('still succeeds when agent_first_name and agent_last_name are omitted', async () => { + it('still succeeds when legacy customer first_name and last_name are omitted', async () => { nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) - const event = createCallCompletedEvent({ agent_first_name: undefined, agent_last_name: undefined }) + const event = createCallCompletedEvent({ first_name: undefined, last_name: undefined }) const responses = await testDestination.testAction('sendCallCompleted', { event, @@ -310,6 +350,22 @@ describe('Voiceops.sendCallCompleted', () => { call_id: 'call-123', agent_email: 'agent@voiceops.com' }) + expect(responses[0].options.json).not.toHaveProperty('first_name') + expect(responses[0].options.json).not.toHaveProperty('last_name') + }) + + it('still succeeds when agent_first_name and agent_last_name are omitted', async () => { + nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) + + const event = createCallCompletedEvent({ agent_first_name: undefined, agent_last_name: undefined }) + + const responses = await testDestination.testAction('sendCallCompleted', { + event, + settings: SETTINGS, + useDefaultMappings: true + }) + + expect(responses[0].status).toBe(200) expect(responses[0].options.json).not.toHaveProperty('agent_first_name') expect(responses[0].options.json).not.toHaveProperty('agent_last_name') }) @@ -471,6 +527,60 @@ describe('Voiceops.sendCallCompleted', () => { ) }) + it('fails when an agent leg last_name is whitespace-only', async () => { + const event = createCallCompletedEvent({ + agentLegs: [ + { + agent_email: 'agent@voiceops.com', + started_at: '2025-12-08T13:32:47.000Z', + first_name: 'Ava', + last_name: ' ' + } + ] + }) + + await expect( + testDestination.testAction('sendCallCompleted', { + event, + settings: SETTINGS, + useDefaultMappings: true + }) + ).rejects.toThrow( + 'agentLegs.last_name is required for every agent leg entry. Provide first_name and last_name, or enable assign_first_last_name_by_splitting_email and provide an agent_email that can be split. Single-token email local-parts derive an empty last_name.' + ) + }) + + it('derives an agent leg last_name when the mapped value is whitespace-only', async () => { + nock(DEFAULT_VOICEOPS_BASE_URL).post('/frontline-api/integrations/v1/segment/calls').reply(200, {}) + + const event = createCallCompletedEvent({ + assign_first_last_name_by_splitting_email: true, + agentLegs: [ + { + agent_email: 'first-agent@voiceops.com', + started_at: '2025-12-08T13:32:47.000Z', + first_name: 'First', + last_name: ' ' + } + ] + }) + + const responses = await testDestination.testAction('sendCallCompleted', { + event, + settings: SETTINGS, + useDefaultMappings: true + }) + + expect(responses[0].options.json).toMatchObject({ + agentLegs: [ + { + first_name: 'First', + last_name: 'Agent' + } + ] + }) + }) + it('fails when a HANDLING_AGENT channel identifier is not an email address', async () => { const event = createCallCompletedEvent({ channels: [ diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/generated-types.ts b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/generated-types.ts index 9e98589062..a8a441ad60 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/generated-types.ts +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/generated-types.ts @@ -18,13 +18,21 @@ export interface Payload { */ recording_url: string /** - * The first name for the customer. + * The first name for the customer. Use this field for new mappings. */ customer_first_name?: string /** - * The last name for the customer. + * The last name for the customer. Use this field for new mappings. */ customer_last_name?: string + /** + * Deprecated legacy first name field currently used for customer names. For new mappings, use Customer First Name. + */ + first_name?: string + /** + * Deprecated legacy last name field currently used for customer names. For new mappings, use Customer Last Name. + */ + last_name?: string /** * The first name for the primary handling agent. */ @@ -34,7 +42,7 @@ export interface Payload { */ agent_last_name?: string /** - * When enabled, missing agent first and last names are derived from agent email addresses by splitting the email local-part. + * When enabled, missing agent first and last names are derived from agent email addresses by splitting the email local-part. Single-token local-parts derive the first name and set last name to an empty string. */ assign_first_last_name_by_splitting_email?: boolean /** @@ -67,7 +75,7 @@ export interface Payload { last_name?: string }[] /** - * Optional warm-transfer metadata for agent handoff windows. Use this when you cannot provide channel-based multi-channel recording data. + * Optional warm-transfer metadata for agent handoff windows. Use this when you cannot provide channel-based multi-channel recording data. Each leg must include first and last name, or enable Assign First / Last Name By Splitting Email and provide a splittable agent email. */ agentLegs?: { /** @@ -83,11 +91,11 @@ export interface Payload { */ ended_at?: string /** - * The first name of the agent for this call leg. + * The first name of the agent for this call leg. Required unless Assign First / Last Name By Splitting Email derives it from agent email. */ first_name?: string /** - * The last name of the agent for this call leg. + * The last name of the agent for this call leg. Required unless Assign First / Last Name By Splitting Email derives it from agent email; single-token email local-parts derive an empty last name. */ last_name?: string }[] diff --git a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts index b307dc706e..2f0434ef3b 100644 --- a/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts +++ b/packages/destination-actions/src/destinations/voiceops/sendCallCompleted/index.ts @@ -9,10 +9,14 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const UNIX_SECONDS_PATTERN = /^\d{10}$/ function titleCase(value: string): string { - return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase() + return value.charAt(0).toUpperCase() + value.slice(1) } function splitNameFromEmail(email?: string): { first_name?: string; last_name?: string } { + if (!email || !EMAIL_REGEX.test(email)) { + return {} + } + const localPart = email?.split('@')[0]?.split('+')[0] const nameParts = localPart?.split(/[^a-zA-Z0-9]+/).filter(Boolean) ?? [] @@ -26,6 +30,14 @@ function splitNameFromEmail(email?: string): { first_name?: string; last_name?: } } +function shouldDeriveFirstName(value?: string): boolean { + return value === undefined || value === null || value.trim() === '' +} + +function shouldDeriveLastName(value?: string): boolean { + return value === undefined || value === null || (value !== '' && value.trim() === '') +} + function withEmailSplitNames(payload: Payload): Payload { if (!payload.assign_first_last_name_by_splitting_email) { return payload @@ -35,15 +47,17 @@ function withEmailSplitNames(payload: Payload): Payload { return { ...payload, - agent_first_name: payload.agent_first_name || primaryAgentName.first_name, - agent_last_name: payload.agent_last_name || primaryAgentName.last_name, + agent_first_name: shouldDeriveFirstName(payload.agent_first_name) + ? primaryAgentName.first_name + : payload.agent_first_name, + agent_last_name: shouldDeriveLastName(payload.agent_last_name) ? primaryAgentName.last_name : payload.agent_last_name, agentLegs: payload.agentLegs?.map((agentLeg) => { const splitName = splitNameFromEmail(agentLeg.agent_email) return { ...agentLeg, - first_name: agentLeg.first_name || splitName.first_name, - last_name: agentLeg.last_name || splitName.last_name + first_name: shouldDeriveFirstName(agentLeg.first_name) ? splitName.first_name : agentLeg.first_name, + last_name: shouldDeriveLastName(agentLeg.last_name) ? splitName.last_name : agentLeg.last_name } }) } @@ -80,7 +94,11 @@ function validateSegmentPayload(payload: Payload): void { ) } - if (agentLeg.last_name === undefined || agentLeg.last_name === null) { + if ( + agentLeg.last_name === undefined || + agentLeg.last_name === null || + (agentLeg.last_name !== '' && agentLeg.last_name.trim() === '') + ) { throw new PayloadValidationError( 'agentLegs.last_name is required for every agent leg entry. Provide first_name and last_name, or enable assign_first_last_name_by_splitting_email and provide an agent_email that can be split. Single-token email local-parts derive an empty last_name.' ) @@ -133,7 +151,7 @@ const action: ActionDefinition = { }, customer_first_name: { label: 'Customer First Name', - description: 'The first name for the customer.', + description: 'The first name for the customer. Use this field for new mappings.', type: 'string', default: { '@path': '$.properties.customer_first_name' @@ -141,12 +159,30 @@ const action: ActionDefinition = { }, customer_last_name: { label: 'Customer Last Name', - description: 'The last name for the customer.', + description: 'The last name for the customer. Use this field for new mappings.', type: 'string', default: { '@path': '$.properties.customer_last_name' } }, + first_name: { + label: 'Customer First Name (Legacy)', + description: + 'Deprecated legacy first name field currently used for customer names. For new mappings, use Customer First Name.', + type: 'string', + default: { + '@path': '$.properties.first_name' + } + }, + last_name: { + label: 'Customer Last Name (Legacy)', + description: + 'Deprecated legacy last name field currently used for customer names. For new mappings, use Customer Last Name.', + type: 'string', + default: { + '@path': '$.properties.last_name' + } + }, agent_first_name: { label: 'Agent First Name', description: 'The first name for the primary handling agent.',