diff --git a/packages/@webex/contact-center/src/services/core/Utils.ts b/packages/@webex/contact-center/src/services/core/Utils.ts index 66d4d44f1cd..a91c9856c6c 100644 --- a/packages/@webex/contact-center/src/services/core/Utils.ts +++ b/packages/@webex/contact-center/src/services/core/Utils.ts @@ -3,6 +3,7 @@ import {LoginOption, WebexRequestPayload} from '../../types'; import {Failure} from './GlobalTypes'; import LoggerProxy from '../../logger-proxy'; import WebexRequest from './WebexRequest'; +import {TaskData, ConsultTransferPayLoad, CONSULT_TRANSFER_DESTINATION_TYPE} from '../task/types'; /** * Extracts common error details from a Webex request payload. @@ -19,6 +20,28 @@ const getCommonErrorDetails = (errObj: WebexRequestPayload) => { }; }; +/** + * Checks if the destination type represents an entry point variant (EPDN or ENTRYPOINT). + */ +const isEntryPointOrEpdn = (destAgentType?: string): boolean => { + return destAgentType === 'EPDN' || destAgentType === 'ENTRYPOINT'; +}; + +/** + * Determines if the task involves dialing a number based on the destination type. + * Returns 'DIAL_NUMBER' for dial-related destinations, empty string otherwise. + */ +const getAgentActionTypeFromTask = (taskData?: TaskData): 'DIAL_NUMBER' | '' => { + const destAgentType = taskData?.destinationType; + + // Check if destination requires dialing: direct dial number or entry point variants + const isDialNumber = destAgentType === 'DN'; + const isEntryPointVariant = isEntryPointOrEpdn(destAgentType); + + // If the destination type is a dial number or an entry point variant, return 'DIAL_NUMBER' + return isDialNumber || isEntryPointVariant ? 'DIAL_NUMBER' : ''; +}; + export const isValidDialNumber = (input: string): boolean => { // This regex checks for a valid dial number format for only few countries such as US, Canada. const regexForDn = /1[0-9]{3}[2-9][0-9]{6}([,]{1,10}[0-9]+){0,1}/; @@ -130,3 +153,29 @@ export const createErrDetailsObject = (errObj: WebexRequestPayload) => { return new Err.Details('Service.reqs.generic.failure', details); }; + +/** + * Derives the consult transfer destination type based on the provided task data. + * + * Logic parity with desktop behavior: + * - If agent action is dialing a number (DN/EPDN/ENTRYPOINT): + * - ENTRYPOINT/EPDN map to ENTRYPOINT + * - DN maps to DIALNUMBER + * - Otherwise defaults to AGENT + * + * @param taskData - The task data used to infer the agent action and destination type + * @returns The normalized destination type to be used for consult transfer + */ +export const deriveConsultTransferDestinationType = ( + taskData?: TaskData +): ConsultTransferPayLoad['destinationType'] => { + const agentActionType = getAgentActionTypeFromTask(taskData); + + if (agentActionType === 'DIAL_NUMBER') { + return isEntryPointOrEpdn(taskData?.destinationType) + ? CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT + : CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER; + } + + return CONSULT_TRANSFER_DESTINATION_TYPE.AGENT; +}; diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index 881cc845723..9bd624298a1 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -270,7 +270,7 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_CONSULTING: // Received when agent is in an active consult state - task = this.updateTaskData(task, payload.data); + // TODO: Check if we can use backend consult state instead of isConsulted if (task.data.isConsulted) { // Fire only if you are the agent who received the consult request task.emit(TASK_EVENTS.TASK_CONSULT_ACCEPTED, task); diff --git a/packages/@webex/contact-center/src/services/task/index.ts b/packages/@webex/contact-center/src/services/task/index.ts index 53473a60c98..70ce58af848 100644 --- a/packages/@webex/contact-center/src/services/task/index.ts +++ b/packages/@webex/contact-center/src/services/task/index.ts @@ -1,7 +1,7 @@ import EventEmitter from 'events'; import {CALL_EVENT_KEYS, LocalMicrophoneStream} from '@webex/calling'; import {CallId} from '@webex/calling/dist/types/common/types'; -import {getErrorDetails} from '../core/Utils'; +import {getErrorDetails, deriveConsultTransferDestinationType} from '../core/Utils'; import {LoginOption} from '../../types'; import {TASK_FILE} from '../../constants'; import {METHODS} from './constants'; @@ -19,7 +19,6 @@ import { ConsultEndPayload, TransferPayLoad, DESTINATION_TYPE, - CONSULT_TRANSFER_DESTINATION_TYPE, ConsultTransferPayLoad, MEDIA_CHANNEL, } from './types'; @@ -1308,61 +1307,70 @@ export default class Task extends EventEmitter implements ITask { * ``` */ public async consultTransfer( - consultTransferPayload: ConsultTransferPayLoad + consultTransferPayload?: ConsultTransferPayLoad ): Promise { try { - LoggerProxy.info(`Initiating consult transfer to ${consultTransferPayload.to}`, { - module: TASK_FILE, - method: METHODS.CONSULT_TRANSFER, - interactionId: this.data.interactionId, - }); + // Resolve the target id (queue consult transfers go to the accepted agent) + if (!this.data.destAgentId) { + throw new Error('No agent has accepted this queue consult yet'); + } - // For queue destinations, use the destAgentId from task data - if (consultTransferPayload.destinationType === CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE) { - if (!this.data.destAgentId) { - throw new Error('No agent has accepted this queue consult yet'); + LoggerProxy.info( + `Initiating consult transfer to ${consultTransferPayload?.to || this.data.destAgentId}`, + { + module: TASK_FILE, + method: METHODS.CONSULT_TRANSFER, + interactionId: this.data.interactionId, } + ); + // Obtain payload based on desktop logic using TaskData + const finalDestinationType = deriveConsultTransferDestinationType(this.data); - // Override the destination with the agent who accepted the queue consult - consultTransferPayload = { - to: this.data.destAgentId, - destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, - }; - } + // By default we always use `destAgentId` as the target id + const consultTransferRequest: ConsultTransferPayLoad = { + to: this.data.destAgentId, + destinationType: finalDestinationType, + }; const result = await this.contact.consultTransfer({ interactionId: this.data.interactionId, - data: consultTransferPayload, + data: consultTransferRequest, }); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, { taskId: this.data.interactionId, - destination: consultTransferPayload.to, - destinationType: consultTransferPayload.destinationType, + destination: consultTransferRequest.to, + destinationType: consultTransferRequest.destinationType, isConsultTransfer: true, ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), }, ['operational', 'behavioral', 'business'] ); - LoggerProxy.log(`Consult transfer completed successfully to ${consultTransferPayload.to}`, { - module: TASK_FILE, - method: METHODS.CONSULT_TRANSFER, - trackingId: result.trackingId, - interactionId: this.data.interactionId, - }); + LoggerProxy.log( + `Consult transfer completed successfully to ${ + consultTransferPayload?.to || this.data.destAgentId + }`, + { + module: TASK_FILE, + method: METHODS.CONSULT_TRANSFER, + trackingId: result.trackingId, + interactionId: this.data.interactionId, + } + ); return result; } catch (error) { const {error: detailedError} = getErrorDetails(error, METHODS.CONSULT_TRANSFER, TASK_FILE); + const failedDestinationType = deriveConsultTransferDestinationType(this.data); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, { taskId: this.data.interactionId, - destination: consultTransferPayload.to, - destinationType: consultTransferPayload.destinationType, + destination: this.data.destAgentId || '', + destinationType: failedDestinationType, isConsultTransfer: true, error: error.toString(), ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts index 453efe8e3d9..89a989c16f2 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts @@ -658,6 +658,13 @@ describe('TaskManager', () => { }); it('should emit TASK_CONSULT_ACCEPTED event on AGENT_CONSULTING event', () => { + const initialConsultingPayload = { + data: { + ...initalPayload.data, + type: CC_EVENTS.AGENT_OFFER_CONSULT, + }, + }; + const consultingPayload = { data: { ...initalPayload.data, @@ -672,8 +679,8 @@ describe('TaskManager', () => { }); const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit'); + webSocketManagerMock.emit('message', JSON.stringify(initialConsultingPayload)); webSocketManagerMock.emit('message', JSON.stringify(consultingPayload)); - expect(taskManager.getTask(taskId).updateTaskData).toHaveBeenCalledWith(consultingPayload.data); expect(taskManager.getTask(taskId).data.isConsulted).toBe(true); expect(taskEmitSpy).toHaveBeenCalledWith( TASK_EVENTS.TASK_CONSULT_ACCEPTED, diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/index.ts b/packages/@webex/contact-center/test/unit/spec/services/task/index.ts index a1fac1890bc..ad035a2aa48 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/index.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/index.ts @@ -754,29 +754,81 @@ describe('Task', () => { expect(contactMock.consult).toHaveBeenCalledWith({interactionId: taskId, data: consultPayload}); expect(response).toEqual(expectedResponse); - const consultTransferPayload: ConsultTransferPayLoad = { - to: '1234', - destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, - }; - - const consultTransferResponse = await task.consultTransfer(consultTransferPayload); + const consultTransferResponse = await task.consultTransfer(); expect(contactMock.consultTransfer).toHaveBeenCalledWith({ interactionId: taskId, - data: consultTransferPayload, + data: { + to: taskDataMock.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, + }, }); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 2, METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, { taskId: taskDataMock.interactionId, - destination: consultTransferPayload.to, - destinationType: consultTransferPayload.destinationType, + destination: taskDataMock.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, isConsultTransfer: true, }, ['operational', 'behavioral', 'business'] ); }); + it('should send DIALNUMBER when task destinationType is DN during consultTransfer', async () => { + const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; + contactMock.consultTransfer.mockResolvedValue(expectedResponse); + + // Ensure task data indicates DN scenario + task.data.destinationType = 'DN' as unknown as string; + + await task.consultTransfer(); + + expect(contactMock.consultTransfer).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + to: taskDataMock.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER, + }, + }); + }); + + it('should send ENTRYPOINT when task destinationType is EPDN during consultTransfer', async () => { + const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; + contactMock.consultTransfer.mockResolvedValue(expectedResponse); + + // Ensure task data indicates EP/EPDN scenario + task.data.destinationType = 'EPDN' as unknown as string; + + await task.consultTransfer(); + + expect(contactMock.consultTransfer).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + to: taskDataMock.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT, + }, + }); + }); + + it('should keep AGENT when task destinationType is neither DN nor EPDN/ENTRYPOINT', async () => { + const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; + contactMock.consultTransfer.mockResolvedValue(expectedResponse); + + // Ensure task data indicates non-DN and non-EP/EPDN scenario + task.data.destinationType = 'SOMETHING_ELSE' as unknown as string; + + await task.consultTransfer(); + + expect(contactMock.consultTransfer).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + to: taskDataMock.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, + }, + }); + }); + it('should do consult transfer to a queue by using the destAgentId from task data', async () => { const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; contactMock.consultTransfer.mockResolvedValue(expectedResponse); @@ -855,8 +907,8 @@ describe('Task', () => { METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, { taskId: taskDataMock.interactionId, - destination: consultTransferPayload.to, - destinationType: consultTransferPayload.destinationType, + destination: taskDataMock.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, isConsultTransfer: true, error: error.toString(), ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details),