From ba92d005f28b4ab0133df28aee1b68def080a9b5 Mon Sep 17 00:00:00 2001 From: Sam Johnson Date: Wed, 30 Jul 2025 14:51:48 -0500 Subject: [PATCH 01/10] Add Salesforce integration example feature --- flex-config/ui_attributes.common.json | 9 ++ .../salesforce-integration/config.ts | 40 +++++++++ .../AssociateRecordDropdown.tsx | 60 ++++++++++++++ .../flex-hooks/actions/AcceptTask.ts | 35 ++++++++ .../flex-hooks/actions/afterCompleteTask.ts | 43 ++++++++++ .../flex-hooks/actions/beforeCompleteTask.ts | 44 ++++++++++ .../flex-hooks/components/TaskCanvas.tsx | 11 +++ .../flex-hooks/events/notesSubmitted.ts | 25 ++++++ .../flex-hooks/events/pluginsInitialized.ts | 45 ++++++++++ .../flex-hooks/events/taskCanceled.ts | 25 ++++++ .../voice-client/incoming.ts | 28 +++++++ .../flex-hooks/notifications/index.ts | 18 ++++ .../flex-hooks/states/index.ts | 58 +++++++++++++ .../flex-hooks/strings/index.ts | 10 +++ .../salesforce-integration/index.ts | 9 ++ .../types/ServiceConfiguration.ts | 9 ++ .../utils/ClickToDial.ts | 74 +++++++++++++++++ .../utils/LogActivity.ts | 70 ++++++++++++++++ .../salesforce-integration/utils/ScreenPop.ts | 82 +++++++++++++++++++ .../utils/SfdcHelper.ts | 17 ++++ .../utils/SfdcLoader.ts | 53 ++++++++++++ 21 files changed, 765 insertions(+) create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/config.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/custom-components/AssociateRecordDropdown.tsx create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/AcceptTask.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/afterCompleteTask.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/beforeCompleteTask.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/notesSubmitted.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/pluginsInitialized.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/taskCanceled.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/jsclient-event-listeners/voice-client/incoming.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/notifications/index.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/states/index.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/strings/index.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/index.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/types/ServiceConfiguration.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ClickToDial.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/LogActivity.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ScreenPop.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/SfdcHelper.ts create mode 100644 plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/SfdcLoader.ts diff --git a/flex-config/ui_attributes.common.json b/flex-config/ui_attributes.common.json index b549b2fb7..573a00d51 100644 --- a/flex-config/ui_attributes.common.json +++ b/flex-config/ui_attributes.common.json @@ -426,6 +426,15 @@ "enabled": false, "exclude_attributes": [], "exclude_queues": [] + }, + "salesforce_integration": { + "enabled": false, + "activity_logging": true, + "click_to_dial": true, + "copilot_notes": true, + "hide_crm_container": true, + "prevent_popout_during_call": true, + "screen_pop": true } } } diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/config.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/config.ts new file mode 100644 index 000000000..81d1eb6cc --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/config.ts @@ -0,0 +1,40 @@ +import { getFeatureFlags } from '../../utils/configuration'; +import SalesforceIntegrationConfig from './types/ServiceConfiguration'; + +const { + enabled = false, + activity_logging = false, + click_to_dial = false, + copilot_notes = false, + hide_crm_container = false, + prevent_popout_during_call = false, + screen_pop = false, +} = (getFeatureFlags()?.features?.salesforce_integration as SalesforceIntegrationConfig) || {}; + +export const isFeatureEnabled = () => { + return enabled; +}; + +export const isActivityLoggingEnabled = () => { + return activity_logging; +}; + +export const isClickToDialEnabled = () => { + return click_to_dial; +}; + +export const isCopilotNotesEnabled = () => { + return copilot_notes; +}; + +export const isHideCrmContainerEnabled = () => { + return hide_crm_container; +}; + +export const isPreventPopoutEnabled = () => { + return prevent_popout_during_call; +}; + +export const isScreenPopEnabled = () => { + return screen_pop; +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/custom-components/AssociateRecordDropdown.tsx b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/custom-components/AssociateRecordDropdown.tsx new file mode 100644 index 000000000..d86a98fc7 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/custom-components/AssociateRecordDropdown.tsx @@ -0,0 +1,60 @@ +import { ITask } from '@twilio/flex-ui'; +import { useSelector } from 'react-redux'; +import { Flex } from '@twilio-paste/core/flex'; +import { Select, Option } from '@twilio-paste/core/select'; + +import AppState from '../../../types/manager/AppState'; +import { reduxNamespace } from '../../../utils/state'; +import { SalesforceIntegrationState } from '../flex-hooks/states'; +import TaskRouterService from '../../../utils/serverless/TaskRouter/TaskRouterService'; + +interface AssociateRecordDropdownProps { + task?: ITask; +} + +const AssociateRecordDropdown = ({ task }: AssociateRecordDropdownProps) => { + const { screenPopSearchResults } = useSelector( + (state: AppState) => state[reduxNamespace].salesforceIntegration as SalesforceIntegrationState, + ); + + if (!task || !screenPopSearchResults[task.sid]) { + return <>; + } + + const recordSelected = async (e: any) => { + const sfdcObjectId = e.target.value; + + if (sfdcObjectId === 'placeholder') { + return; + } + + const record = screenPopSearchResults[task.sid].find((item) => item.id === sfdcObjectId); + + if (!record) { + return; + } + + await TaskRouterService.updateTaskAttributes(task.taskSid, { sfdcObjectId, sfdcObjectType: record.type }); + }; + + return ( + + + + ); +}; + +export default AssociateRecordDropdown; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/AcceptTask.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/AcceptTask.ts new file mode 100644 index 000000000..907677603 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/AcceptTask.ts @@ -0,0 +1,35 @@ +import * as Flex from '@twilio/flex-ui'; + +import { FlexActionEvent, FlexAction } from '../../../../types/feature-loader'; +import { screenPop } from '../../utils/ScreenPop'; +import { getOpenCti } from '../../utils/SfdcHelper'; +import logger from '../../../../utils/logger'; +import { isScreenPopEnabled } from '../../config'; + +export const actionEvent = FlexActionEvent.after; +export const actionName = FlexAction.AcceptTask; +export const actionHook = function screenPopAfterAccept(flex: typeof Flex) { + flex.Actions.addListener(`${actionEvent}${actionName}`, async (payload) => { + if (!isScreenPopEnabled() || !getOpenCti()) { + return; + } + + let task; + + if (payload.task) { + task = payload.task; + } else if (payload.sid) { + task = flex.TaskHelper.getTaskByTaskSid(payload.sid); + } + + if (!task) { + return; + } + + try { + screenPop(task); + } catch (error: any) { + logger.error('[salesforce-integration] Error calling Open CTI screenPop', error); + } + }); +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/afterCompleteTask.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/afterCompleteTask.ts new file mode 100644 index 000000000..944e7c9a4 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/afterCompleteTask.ts @@ -0,0 +1,43 @@ +import * as Flex from '@twilio/flex-ui'; + +import { FlexActionEvent, FlexAction } from '../../../../types/feature-loader'; +import { saveLog } from '../../utils/LogActivity'; +import { getOpenCti } from '../../utils/SfdcHelper'; +import logger from '../../../../utils/logger'; +import { isActivityLoggingEnabled } from '../../config'; + +export const actionEvent = FlexActionEvent.after; +export const actionName = FlexAction.CompleteTask; +export const actionHook = function saveCallLog(flex: typeof Flex) { + flex.Actions.addListener(`${actionEvent}${actionName}`, async (payload) => { + if (!isActivityLoggingEnabled() || !getOpenCti()) { + return; + } + + let task; + + if (payload.task) { + task = payload.task; + } else if (payload.sid) { + task = flex.TaskHelper.getTaskByTaskSid(payload.sid); + } + + if (!task) { + return; + } + + try { + logger.log('[salesforce-integration] Saving task log', task.taskSid); + saveLog(task, 'Completed', (response: any) => { + if (response.success) { + logger.log('[salesforce-integration] Saved task log', response.returnValue); + (window as any).sforce.opencti.refreshView(); + return; + } + logger.error('[salesforce-integration] Unable to save task log', response.errors); + }); + } catch (error: any) { + logger.error('[salesforce-integration] Error calling Open CTI saveLog', error); + } + }); +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/beforeCompleteTask.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/beforeCompleteTask.ts new file mode 100644 index 000000000..1df20f2b0 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/beforeCompleteTask.ts @@ -0,0 +1,44 @@ +import * as Flex from '@twilio/flex-ui'; + +import { FlexActionEvent, FlexAction } from '../../../../types/feature-loader'; +import { getOpenCti } from '../../utils/SfdcHelper'; +import logger from '../../../../utils/logger'; +import { isActivityLoggingEnabled } from '../../config'; +import { reduxNamespace } from '../../../../utils/state'; +import { AppState } from '../../../../types/manager'; +import { SalesforceIntegrationNotification } from '../notifications'; + +export const actionEvent = FlexActionEvent.before; +export const actionName = FlexAction.CompleteTask; +export const actionHook = function checkRecordAssociation(flex: typeof Flex, manager: Flex.Manager) { + flex.Actions.addListener(`${actionEvent}${actionName}`, async (payload, abortFunction) => { + if (!isActivityLoggingEnabled() || !getOpenCti()) { + return; + } + + let task; + + if (payload.task) { + task = payload.task; + } else if (payload.sid) { + task = flex.TaskHelper.getTaskByTaskSid(payload.sid); + } + + if (!task) { + return; + } + + if ( + !task.attributes.sfdcObjectId && + (manager.store.getState() as AppState)[reduxNamespace].salesforceIntegration.screenPopSearchResults[task.sid] + ?.length + ) { + // If no record ID saved, but search results in Redux, abort and notify + flex.Notifications.showNotification(SalesforceIntegrationNotification.AssociationRequired); + logger.warn( + '[salesforce-integration] Task completion prevented while waiting for user to select record to associate', + ); + abortFunction(); + } + }); +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx new file mode 100644 index 000000000..bfde9890a --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx @@ -0,0 +1,11 @@ +import * as Flex from '@twilio/flex-ui'; + +import AssociateRecordDropdown from '../../custom-components/AssociateRecordDropdown'; +import { FlexComponent } from '../../../../types/feature-loader'; + +export const componentName = 'TaskCanvas'; // FlexComponent.TaskCanvas +export const componentHook = function addAsssociateRecordDropdownToCanvas(flex: typeof Flex, manager: Flex.Manager) { + flex.TaskCanvas.Content.add(, { + sortOrder: 1, + }); +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/notesSubmitted.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/notesSubmitted.ts new file mode 100644 index 000000000..d44953612 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/notesSubmitted.ts @@ -0,0 +1,25 @@ +import * as Flex from '@twilio/flex-ui'; + +import { FlexEvent } from '../../../../types/feature-loader'; +import { setChannelNote } from '../states'; +import logger from '../../../../utils/logger'; +import { isCopilotNotesEnabled } from '../../config'; + +//export const eventName = FlexEvent.notesSubmitted; +export const eventHook = async function collectCopilotNotes(flex: typeof Flex, manager: Flex.Manager, notes: any) { + if (!isCopilotNotesEnabled()) { + return; + } + logger.log('[salesforce-integration] Received agent copilot wrapup summary', notes); + manager.store.dispatch( + setChannelNote({ + channelSid: notes.channelSid, + dispositionCode: { + disposition_code: notes.dispositionCode.disposition_code, + topic_path: notes.dispositionCode.topic_path, + }, + sentiment: notes.sentiment, + summary: notes.summary, + }), + ); +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/pluginsInitialized.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/pluginsInitialized.ts new file mode 100644 index 000000000..0f818086c --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/pluginsInitialized.ts @@ -0,0 +1,45 @@ +import * as Flex from '@twilio/flex-ui'; + +import { initializeSalesforceAPIs } from '../../utils/SfdcLoader'; +import { FlexEvent } from '../../../../types/feature-loader'; +import { getSfdcBaseUrl, isSalesforce } from '../../utils/SfdcHelper'; +import { enableClickToDial } from '../../utils/ClickToDial'; +import logger from '../../../../utils/logger'; +import { isClickToDialEnabled, isHideCrmContainerEnabled } from '../../config'; + +export const eventName = FlexEvent.pluginsInitialized; +export const eventHook = async function loadOpenCti(flex: typeof Flex, manager: Flex.Manager) { + if (isSalesforce(getSfdcBaseUrl()) && isHideCrmContainerEnabled()) { + // Hide Flex's own CRM container while we are embedded within Salesforce + manager.updateConfig({ + componentProps: { + AgentDesktopView: { + showPanel2: false, + }, + }, + }); + } + + let openCtiLoaded = false; + try { + openCtiLoaded = await initializeSalesforceAPIs(); + } catch (error: any) { + logger.error('[salesforce-integration] Error initializing Open CTI', error); + } + + if (!openCtiLoaded) { + return; + } + logger.log('[salesforce-integration] Open CTI loaded'); + + if (!isClickToDialEnabled()) { + return; + } + + try { + logger.log('[salesforce-integration] Enabling click-to-dial'); + enableClickToDial(); + } catch (error: any) { + logger.error('[salesforce-integration] Error calling Open CTI enableClickToDial', error); + } +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/taskCanceled.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/taskCanceled.ts new file mode 100644 index 000000000..ac8e4e6b5 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/events/taskCanceled.ts @@ -0,0 +1,25 @@ +import * as Flex from '@twilio/flex-ui'; + +import { FlexEvent } from '../../../../types/feature-loader'; +import { saveLog } from '../../utils/LogActivity'; +import { getOpenCti } from '../../utils/SfdcHelper'; + +export const eventName = FlexEvent.taskCanceled; +export const eventHook = function saveCanceledCallLog(flex: typeof Flex, manager: Flex.Manager, task: Flex.ITask) { + try { + if (!getOpenCti()) { + return; + } + console.log('[salesforce-integration] Saving canceled task log', task.taskSid); + saveLog(task, 'Canceled', (response: any) => { + if (response.success) { + console.log('[salesforce-integration] Saved canceled task log', response.returnValue); + (window as any).sforce.opencti.refreshView(); + return; + } + console.error('[salesforce-integration] Unable to save canceled task log', response.errors); + }); + } catch (error) { + console.error('[salesforce-integration] Error calling Open CTI saveLog', error); + } +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/jsclient-event-listeners/voice-client/incoming.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/jsclient-event-listeners/voice-client/incoming.ts new file mode 100644 index 000000000..4ee5d16d8 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/jsclient-event-listeners/voice-client/incoming.ts @@ -0,0 +1,28 @@ +import * as Flex from '@twilio/flex-ui'; +import { Call } from '@twilio/voice-sdk'; + +import { FlexJsClient, VoiceEvent } from '../../../../../types/feature-loader'; +import { getConsole } from '../../../utils/SfdcHelper'; +import logger from '../../../../../utils/logger'; +import { isPreventPopoutEnabled } from '../../../config'; + +export const clientName = FlexJsClient.voiceClient; +export const eventName = VoiceEvent.incoming; +export const jsClientHook = (_flex: typeof Flex, manager: Flex.Manager, call: Call) => { + if (!isPreventPopoutEnabled() || !getConsole()) { + // We cannot do anything if the console API has not been loaded or if this functionality is disabled + return; + } + + getConsole().setCustomConsoleComponentPopoutable(false, (result: any) => { + logger.log(`[salesforce-integration] Disable popoutable ${result.success ? 'successful' : 'failed'}`); + }); + + for (const event of ['cancel', 'disconnect', 'error', 'reject']) { + call.on(event, () => { + getConsole().setCustomConsoleComponentPopoutable(true, (result: any) => { + logger.log(`[salesforce-integration] Enable popoutable ${result.success ? 'successful' : 'failed'}`); + }); + }); + } +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/notifications/index.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/notifications/index.ts new file mode 100644 index 000000000..fa85a64fe --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/notifications/index.ts @@ -0,0 +1,18 @@ +import * as Flex from '@twilio/flex-ui'; + +import { StringTemplates } from '../strings'; + +// Export the notification IDs an enum for better maintainability when accessing them elsewhere +export enum SalesforceIntegrationNotification { + AssociationRequired = 'PSSalesforceAssociationRequired', +} + +// Return an array of Flex.Notification +export const notificationHook = () => [ + { + id: SalesforceIntegrationNotification.AssociationRequired, + type: Flex.NotificationType.error, + content: StringTemplates.AssociationRequired, + timeout: 3500, + }, +]; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/states/index.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/states/index.ts new file mode 100644 index 000000000..65630b9f0 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/states/index.ts @@ -0,0 +1,58 @@ +import { createSlice } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; + +export interface ChannelNote { + channelSid: string; + dispositionCode: { + topic_path: string; + disposition_code: string; + }; + sentiment: string; + summary: string; +} + +export interface ScreenPopSearchResult { + id: string; + type: string; + name: string; +} + +export interface ScreenPopSearchResultsPayload { + reservationSid: string; + results: ScreenPopSearchResult[]; +} + +export interface SalesforceIntegrationState { + channelNotes: { [channelSid: string]: ChannelNote }; + screenPopSearchResults: { [reservationSid: string]: ScreenPopSearchResult[] }; +} + +const initialState = { + channelNotes: {}, + screenPopSearchResults: {}, +} as SalesforceIntegrationState; + +const salesforceIntegrationSlice = createSlice({ + name: 'salesforceIntegration', + initialState, + reducers: { + setChannelNote(state, action: PayloadAction) { + state.channelNotes[action.payload.channelSid] = action.payload; + }, + clearChannelNote(state, action: PayloadAction) { + if (!state.channelNotes[action.payload]) return; + delete state.channelNotes[action.payload]; + }, + setSearchResults(state, action: PayloadAction) { + state.screenPopSearchResults[action.payload.reservationSid] = action.payload.results; + }, + clearSearchResults(state, action: PayloadAction) { + if (!state.screenPopSearchResults[action.payload]) return; + delete state.screenPopSearchResults[action.payload]; + }, + }, +}); + +export const { setChannelNote, clearChannelNote, setSearchResults, clearSearchResults } = + salesforceIntegrationSlice.actions; +export const reducerHook = () => ({ salesforceIntegration: salesforceIntegrationSlice.reducer }); diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/strings/index.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/strings/index.ts new file mode 100644 index 000000000..6b4827f2a --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/strings/index.ts @@ -0,0 +1,10 @@ +// Export the template names as an enum for better maintainability when accessing them elsewhere +export enum StringTemplates { + AssociationRequired = 'PSSalesforceAssociationRequired', +} + +export const stringHook = () => ({ + 'en-US': { + [StringTemplates.AssociationRequired]: 'Please select a Salesforce record to associate before completing the task.', + }, +}); diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/index.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/index.ts new file mode 100644 index 000000000..c8aac8778 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/index.ts @@ -0,0 +1,9 @@ +import { FeatureDefinition } from '../../types/feature-loader'; +import { isFeatureEnabled } from './config'; +// @ts-ignore +import hooks from './flex-hooks/**/*.*'; + +export const register = (): FeatureDefinition => { + if (!isFeatureEnabled()) return {}; + return { name: 'salesforce-integration', hooks: typeof hooks === 'undefined' ? [] : hooks }; +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/types/ServiceConfiguration.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/types/ServiceConfiguration.ts new file mode 100644 index 000000000..fcfc0a7f5 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/types/ServiceConfiguration.ts @@ -0,0 +1,9 @@ +export default interface SalesforceIntegrationConfig { + enabled: boolean; + activity_logging: boolean; + click_to_dial: boolean; + copilot_notes: boolean; + hide_crm_container: boolean; + prevent_popout_during_call: boolean; + screen_pop: boolean; +} diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ClickToDial.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ClickToDial.ts new file mode 100644 index 000000000..809a6798c --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ClickToDial.ts @@ -0,0 +1,74 @@ +import { Actions, Manager, StateHelper } from '@twilio/flex-ui'; + +import { getOpenCti } from './SfdcHelper'; +import logger from '../../../utils/logger'; + +const handleClickToDial = async (response: any) => { + logger.log('[salesforce-integration] Performing click-to-dial', response); + + // Check that this is the active Flex session + const { singleSessionGuard } = Manager.getInstance().store.getState().flex.session; + if (singleSessionGuard?.sessionInvalidated || singleSessionGuard?.otherSessionDetected) { + logger.error( + '[salesforce-integration] Outbound call not made as Flex session not valid per single session guard', + singleSessionGuard, + ); + return; + } + + // Check that the worker is not already on a call + if (StateHelper.getCurrentPhoneCallState()) { + logger.error('[salesforce-integration] Outbound call not made as user is already on a call'); + return; + } + + // If the Flex softphone is not visible, make it so + getOpenCti().isSoftphonePanelVisible({ + callback: (response: any) => { + if (response.success && !response.returnValue?.visible) { + // Engage! + getOpenCti().setSoftphonePanelVisibility({ visible: true }); + } + }, + }); + + let sfdcObjectId = response.recordId; + let sfdcObjectType = response.objectType; + + if (response.personAccount && response.contactId) { + // When dialed from a Person Account record, prefer associating to the corresponding Contact record + sfdcObjectId = response.contactId; + sfdcObjectType = 'Contact'; + } + + try { + await Actions.invokeAction('StartOutboundCall', { + destination: response.number, + taskAttributes: { + sfdcObjectId, + sfdcObjectType, + }, + }); + } catch (error: any) { + logger.error('[salesforce-integration] Error calling StartOutboundCall', error); + } +}; + +export const enableClickToDial = () => { + if (!getOpenCti()) { + return; + } + + getOpenCti().enableClickToDial({ + callback: (response: any) => { + if (!response.success) { + logger.error('[salesforce-integration] Failed to enable click-to-dial', response); + return; + } + + getOpenCti().onClickToDial({ + listener: handleClickToDial, + }); + }, + }); +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/LogActivity.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/LogActivity.ts new file mode 100644 index 000000000..5ee3e5038 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/LogActivity.ts @@ -0,0 +1,70 @@ +import { Manager, ITask, TaskHelper } from '@twilio/flex-ui'; + +import { getOpenCti } from './SfdcHelper'; +import AppState from '../../../types/manager/AppState'; +import { reduxNamespace } from '../../../utils/state'; +import { clearChannelNote } from '../flex-hooks/states'; +import logger from '../../../utils/logger'; +import { isCopilotNotesEnabled } from '../config'; + +export const saveLog = (task: ITask, event: string, callback: any) => { + if (!getOpenCti()) { + return; + } + + const { channelType } = task; + const channelName = channelType === 'web' ? 'chat' : channelType; + const isVoice = TaskHelper.isCallTask(task); + const { direction } = task.attributes; + const callType = direction === 'outbound' ? getOpenCti().CALL_TYPE.OUTBOUND : getOpenCti().CALL_TYPE.INBOUND; + + const value = { + entityApiName: 'Task', + ActivityDate: task.dateUpdated, + CallType: callType, + Description: task.attributes.conversations?.content ?? '', + Status: 'Completed', + Subject: `[${event}] ${callType} ${channelName} at ${task.dateUpdated}`, + Type: callType, + CallObject: task.attributes.conference?.participants?.worker ?? '', + CallDisposition: task.attributes.conversations?.outcome ?? '', + TaskSubtype: isVoice ? 'Call' : 'Task', + } as any; + + if (isVoice) { + const phone = direction === 'outbound' ? task.attributes.outbound_to : task.attributes.from; + if (phone) { + value.Phone = phone; + } + } + + if ( + !task.attributes.sfdcObjectType || + task.attributes.sfdcObjectType === 'Contact' || + task.attributes.sfdcObjectType === 'Lead' + ) { + value.WhoId = task.attributes.sfdcObjectId ?? ''; + } else { + value.WhatId = task.attributes.sfdcObjectId ?? ''; + } + + if (isCopilotNotesEnabled()) { + const manager = Manager.getInstance(); + const state = manager.store.getState() as AppState; + const { channelNotes } = state[reduxNamespace].salesforceIntegration; + const channelSid = task.conference?.source?.channel?.sid; + + if (channelSid && channelNotes && channelNotes[channelSid]) { + // Agent copilot wrapup summary is available; use it instead + // Additional fields are available too (topic, sentiment) + logger.log('[salesforce-integration] Using agent copilot wrapup summary for call log'); + value.CallDisposition = channelNotes[channelSid].dispositionCode.disposition_code; + value.Description = channelNotes[channelSid].summary; + manager.store.dispatch(clearChannelNote(channelSid)); + } + } + + logger.log('[salesforce-integration] Saving call log', value); + + getOpenCti().saveLog({ value, callback }); +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ScreenPop.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ScreenPop.ts new file mode 100644 index 000000000..454f5bdeb --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ScreenPop.ts @@ -0,0 +1,82 @@ +import { Manager, ITask } from '@twilio/flex-ui'; + +import TaskRouterService from '../../../utils/serverless/TaskRouter/TaskRouterService'; +import { getOpenCti } from './SfdcHelper'; +import logger from '../../../utils/logger'; +import { setSearchResults } from '../flex-hooks/states'; + +export const screenPop = (task: ITask) => { + if (!getOpenCti() || !task) { + return; + } + + if (task.attributes.direction === 'outbound') { + // Skip screen pop for outbound tasks as the user is likely already viewing the desired record + logger.log(`[salesforce-integration] Skipping screen pop for outbound task ${task.taskSid}`); + return; + } + + // Handle single match record ID passed from task attributes - set via Studio flow + const sfdcObjectId = task.attributes.sfdcObjectId && task.attributes.sfdcObjectId.trim(); + if (sfdcObjectId) { + logger.log(`[salesforce-integration] Performing screen pop of record ${sfdcObjectId} for task ${task.taskSid}`); + getOpenCti().screenPop({ + type: getOpenCti().SCREENPOP_TYPE.SOBJECT, + params: { recordId: sfdcObjectId }, + callback: (result: any) => { + if (!result.success) { + logger.error('[salesforce-integration] Failed to screen pop single record match', result); + } + }, + }); + return; + } + + // No single match provided; perform a search and pop based on Salesforce softphone layout settings + const searchParams = + task.attributes.name || task.attributes.from || task.attributes.identity || task.attributes.customerAddress; + + logger.log( + `[salesforce-integration] Performing search and screen pop with params "${searchParams}" for task ${task.taskSid}`, + ); + getOpenCti().searchAndScreenPop({ + searchParams, + defaultFieldValues: { + Phone: searchParams, + }, + callType: getOpenCti().CALL_TYPE.INBOUND, + deferred: false, // We could set this true to perform processing or pop conditionally based on the result + callback: async (result: any) => { + if (!result.success) { + logger.error('[salesforce-integration] Failed to search and screen pop', result); + return; + } + + const recordIds = Object.keys(result.returnValue); + if (recordIds.length === 1) { + // Single match; store on task for activity logging + try { + logger.log('[salesforce-integration] Saving single match record ID to task', { + recordId: recordIds[0], + taskSid: task.taskSid, + }); + await TaskRouterService.updateTaskAttributes(task.taskSid, { + sfdcObjectId: recordIds[0], + sfdcObjectType: result.returnValue[recordIds[0]].RecordType, + }); + } catch (error: any) { + logger.log('[salesforce-integration] Unable to update task', error); + } + } else if (recordIds.length > 1) { + // Multiple match + const results = Object.values(result.returnValue).map((item: any) => ({ + id: item.Id, + name: item.Name ?? item.Id, + type: item.RecordType, + })); + Manager.getInstance().store.dispatch(setSearchResults({ reservationSid: task.sid, results })); + logger.log('[salesforce-integration] Multiple matches', result.returnValue); + } + }, + }); +}; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/SfdcHelper.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/SfdcHelper.ts new file mode 100644 index 000000000..3d5e21aa9 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/SfdcHelper.ts @@ -0,0 +1,17 @@ +const getSforce = () => (window as any).sforce; + +export const getConsole = () => getSforce()?.console; + +export const getOpenCti = () => getSforce()?.opencti; + +export const getSfdcBaseUrl = () => { + try { + return window.location.ancestorOrigins[0]; + } catch { + // ancestorOrigins is not a web standard; handle non-chromium browsers here + return ''; + } +}; + +export const isSalesforce = (baseUrl: string) => + Boolean(baseUrl) && window.self !== window.top && baseUrl.includes('.lightning.force.com'); diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/SfdcLoader.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/SfdcLoader.ts new file mode 100644 index 000000000..3e12c4b66 --- /dev/null +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/SfdcLoader.ts @@ -0,0 +1,53 @@ +import { getConsole, getOpenCti, getSfdcBaseUrl, isSalesforce } from './SfdcHelper'; +import logger from '../../../utils/logger'; + +export const loadScript = async (url: string) => + new Promise((resolve, reject) => { + const scriptRef = document.createElement('script'); + const tag = document.getElementsByTagName('script')[0]; + + const onLoaded = (res: any) => (!res.readyState || res.readyState === 'complete') && resolve(res); + + scriptRef.src = url; + scriptRef.type = 'text/javascript'; + scriptRef.async = true; + scriptRef.onerror = reject; + scriptRef.onload = onLoaded; + (scriptRef as any).onreadystatechange = onLoaded; + + tag.parentNode?.insertBefore(scriptRef, tag); + }); + +export const initializeSalesforceAPIs = async () => { + const sfdcBaseUrl = getSfdcBaseUrl(); + + if (!isSalesforce(sfdcBaseUrl)) { + // Continue as usual + logger.warn( + '[salesforce-integration] Not initializing Salesforce APIs since this instance has been launched independently.', + ); + return false; + } + + // We only need to load Open CTI if another plugin has not done so already + if (!getOpenCti()) { + logger.log('[salesforce-integration] Loading Open CTI API...'); + const sfOpenCTIScriptUrl = `${sfdcBaseUrl}/support/api/52.0/lightning/opencti_min.js`; + await loadScript(sfOpenCTIScriptUrl); + } + + // We only need to load console APIs if another plugin has not done so already + if (!getConsole()) { + logger.log('[salesforce-integration] Loading Console Integration API...'); + const sfConsoleAPIScriptUrl = `${sfdcBaseUrl}/support/console/52.0/integration.js`; + await loadScript(sfConsoleAPIScriptUrl); + } + + if (!getOpenCti()) { + // We care mostly that Open CTI loaded + logger.error('[salesforce-integration] Salesforce APIs cannot be found'); + return false; + } + + return true; +}; From bb2e3d0e5df62b8aae27263a13baabaeaa935a71 Mon Sep 17 00:00:00 2001 From: Sam Johnson Date: Fri, 1 Aug 2025 10:59:39 -0500 Subject: [PATCH 02/10] Add TaskCanvas to components enum --- docs/docs/building/flex-hooks/components.md | 1 + .../flex-hooks/components/TaskCanvas.tsx | 4 ++-- .../src/types/feature-loader/FlexComponent.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/docs/building/flex-hooks/components.md b/docs/docs/building/flex-hooks/components.md index a7728cd62..3027c42dc 100644 --- a/docs/docs/building/flex-hooks/components.md +++ b/docs/docs/building/flex-hooks/components.md @@ -38,6 +38,7 @@ enum FlexComponent { QueueStats = 'QueueStats', SideNav = 'SideNav', SupervisorTaskCanvasHeader = 'SupervisorTaskCanvasHeader', + TaskCanvas = 'TaskCanvas', TaskCanvasHeader = 'TaskCanvasHeader', TaskCanvasTabs = 'TaskCanvasTabs', TaskCard = 'TaskCard', diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx index bfde9890a..64747abe2 100644 --- a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx @@ -3,8 +3,8 @@ import * as Flex from '@twilio/flex-ui'; import AssociateRecordDropdown from '../../custom-components/AssociateRecordDropdown'; import { FlexComponent } from '../../../../types/feature-loader'; -export const componentName = 'TaskCanvas'; // FlexComponent.TaskCanvas -export const componentHook = function addAsssociateRecordDropdownToCanvas(flex: typeof Flex, manager: Flex.Manager) { +export const componentName = FlexComponent.TaskCanvas; +export const componentHook = function addAsssociateRecordDropdownToCanvas(flex: typeof Flex) { flex.TaskCanvas.Content.add(, { sortOrder: 1, }); diff --git a/plugin-flex-ts-template-v2/src/types/feature-loader/FlexComponent.ts b/plugin-flex-ts-template-v2/src/types/feature-loader/FlexComponent.ts index 0e49fd240..f4ea4b8be 100644 --- a/plugin-flex-ts-template-v2/src/types/feature-loader/FlexComponent.ts +++ b/plugin-flex-ts-template-v2/src/types/feature-loader/FlexComponent.ts @@ -19,6 +19,7 @@ export enum FlexComponent { QueueStats = 'QueueStats', SideNav = 'SideNav', SupervisorTaskCanvasHeader = 'SupervisorTaskCanvasHeader', + TaskCanvas = 'TaskCanvas', TaskCanvasHeader = 'TaskCanvasHeader', TaskCanvasTabs = 'TaskCanvasTabs', TaskCard = 'TaskCard', From 02606d192f9709f9928ffbdc4c53b8ffe97570e2 Mon Sep 17 00:00:00 2001 From: Sam Johnson Date: Mon, 4 Aug 2025 19:39:20 -0500 Subject: [PATCH 03/10] Show notification for click-to-dial during call --- .../flex-hooks/notifications/index.ts | 7 +++++++ .../salesforce-integration/flex-hooks/strings/index.ts | 2 ++ .../salesforce-integration/utils/ClickToDial.ts | 4 +++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/notifications/index.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/notifications/index.ts index fa85a64fe..d9d92750f 100644 --- a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/notifications/index.ts +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/notifications/index.ts @@ -5,6 +5,7 @@ import { StringTemplates } from '../strings'; // Export the notification IDs an enum for better maintainability when accessing them elsewhere export enum SalesforceIntegrationNotification { AssociationRequired = 'PSSalesforceAssociationRequired', + AlreadyOnPhone = 'PSSalesforceAlreadyOnPhone', } // Return an array of Flex.Notification @@ -15,4 +16,10 @@ export const notificationHook = () => [ content: StringTemplates.AssociationRequired, timeout: 3500, }, + { + id: SalesforceIntegrationNotification.AlreadyOnPhone, + type: Flex.NotificationType.error, + content: StringTemplates.AlreadyOnPhone, + timeout: 3500, + }, ]; diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/strings/index.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/strings/index.ts index 6b4827f2a..cd368a05e 100644 --- a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/strings/index.ts +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/strings/index.ts @@ -1,10 +1,12 @@ // Export the template names as an enum for better maintainability when accessing them elsewhere export enum StringTemplates { AssociationRequired = 'PSSalesforceAssociationRequired', + AlreadyOnPhone = 'PSSalesforceAlreadyOnPhone', } export const stringHook = () => ({ 'en-US': { [StringTemplates.AssociationRequired]: 'Please select a Salesforce record to associate before completing the task.', + [StringTemplates.AlreadyOnPhone]: 'You must end your current call before placing a new call.', }, }); diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ClickToDial.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ClickToDial.ts index 809a6798c..1b638ceba 100644 --- a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ClickToDial.ts +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/utils/ClickToDial.ts @@ -1,7 +1,8 @@ -import { Actions, Manager, StateHelper } from '@twilio/flex-ui'; +import { Actions, Manager, Notifications, StateHelper } from '@twilio/flex-ui'; import { getOpenCti } from './SfdcHelper'; import logger from '../../../utils/logger'; +import { SalesforceIntegrationNotification } from '../flex-hooks/notifications'; const handleClickToDial = async (response: any) => { logger.log('[salesforce-integration] Performing click-to-dial', response); @@ -18,6 +19,7 @@ const handleClickToDial = async (response: any) => { // Check that the worker is not already on a call if (StateHelper.getCurrentPhoneCallState()) { + Notifications.showNotification(SalesforceIntegrationNotification.AlreadyOnPhone); logger.error('[salesforce-integration] Outbound call not made as user is already on a call'); return; } From 66614cc42acb68c42ae2192801107d1ec77b1b64 Mon Sep 17 00:00:00 2001 From: Sam Johnson Date: Mon, 4 Aug 2025 19:39:50 -0500 Subject: [PATCH 04/10] Perform less work in certain configurations --- .../flex-hooks/actions/beforeCompleteTask.ts | 4 ++-- .../flex-hooks/components/TaskCanvas.tsx | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/beforeCompleteTask.ts b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/beforeCompleteTask.ts index 1df20f2b0..03b4e915f 100644 --- a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/beforeCompleteTask.ts +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/actions/beforeCompleteTask.ts @@ -3,7 +3,7 @@ import * as Flex from '@twilio/flex-ui'; import { FlexActionEvent, FlexAction } from '../../../../types/feature-loader'; import { getOpenCti } from '../../utils/SfdcHelper'; import logger from '../../../../utils/logger'; -import { isActivityLoggingEnabled } from '../../config'; +import { isActivityLoggingEnabled, isScreenPopEnabled } from '../../config'; import { reduxNamespace } from '../../../../utils/state'; import { AppState } from '../../../../types/manager'; import { SalesforceIntegrationNotification } from '../notifications'; @@ -12,7 +12,7 @@ export const actionEvent = FlexActionEvent.before; export const actionName = FlexAction.CompleteTask; export const actionHook = function checkRecordAssociation(flex: typeof Flex, manager: Flex.Manager) { flex.Actions.addListener(`${actionEvent}${actionName}`, async (payload, abortFunction) => { - if (!isActivityLoggingEnabled() || !getOpenCti()) { + if (!isActivityLoggingEnabled() || !isScreenPopEnabled() || !getOpenCti()) { return; } diff --git a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx index 64747abe2..dda30e18f 100644 --- a/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx +++ b/plugin-flex-ts-template-v2/src/feature-library/salesforce-integration/flex-hooks/components/TaskCanvas.tsx @@ -2,9 +2,13 @@ import * as Flex from '@twilio/flex-ui'; import AssociateRecordDropdown from '../../custom-components/AssociateRecordDropdown'; import { FlexComponent } from '../../../../types/feature-loader'; +import { isActivityLoggingEnabled, isScreenPopEnabled } from '../../config'; export const componentName = FlexComponent.TaskCanvas; export const componentHook = function addAsssociateRecordDropdownToCanvas(flex: typeof Flex) { + if (!isActivityLoggingEnabled() || !isScreenPopEnabled()) { + return; + } flex.TaskCanvas.Content.add(, { sortOrder: 1, }); From d18e4649cda97192904b5c5334a796b7e6cc782f Mon Sep 17 00:00:00 2001 From: Sam Johnson Date: Mon, 4 Aug 2025 19:40:28 -0500 Subject: [PATCH 05/10] Add docs --- docs/docs/feature-library/00_overview.md | 1 + .../feature-library/salesforce-integration.md | 116 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 docs/docs/feature-library/salesforce-integration.md diff --git a/docs/docs/feature-library/00_overview.md b/docs/docs/feature-library/00_overview.md index 662030a1d..57185c324 100644 --- a/docs/docs/feature-library/00_overview.md +++ b/docs/docs/feature-library/00_overview.md @@ -64,6 +64,7 @@ The **Flex Project Template** comes with a set of features enabled by default wi | [Omni Channel Management](omni-channel-capacity-management) | _method for mixing chat and voice channels_ | | | [Queues Stats Metrics](queues-stats-metrics) | _add custom metrics columns to the Queues View_ | | | [Ring Notification](ring-notification) | _plays a ringtone sound for incoming tasks_ | | +| [Salesforce Integration](salesforce-integration) | _example starting point for a custom Salesforce integration_ | | | [Scrollable Activities](scrollable-activities) | _allow the scrolling of the activities list_ | | | [SIP Support](sip-support) | _adds call control functionality when using a non-WebRTC phone_ | | diff --git a/docs/docs/feature-library/salesforce-integration.md b/docs/docs/feature-library/salesforce-integration.md new file mode 100644 index 000000000..56372a93d --- /dev/null +++ b/docs/docs/feature-library/salesforce-integration.md @@ -0,0 +1,116 @@ +--- +sidebar_label: salesforce-integration +title: salesforce-integration +--- + +## Overview + +This feature provides an enhanced Salesforce integration which replaces the out-of-box Salesforce integration plugin. It can be used as a starting point for customizing Salesforce integration functionality, which is not possible when using the out-of-box integration. + +Functionality included within this implementation: +- Activity logging + - Creates activity upon task completion or cancellation + - Includes agent copilot disposition and summary + - Relates the activity to the dialed or screen-popped record, or the agent-selected record +- Click-to-dial +- Screen pop +- UI enhancements + - Disables the pop-out and pop-in buttons while on a call, to prevent accidental call hangups + - Hides the CRM container when embedded + - When screen pop returns multiple records, a dropdown is added to the interface for the agent to select the appropriate record for activity logging + +--- + +## Business Details + +### Context + +Flex includes a Salesforce integration out-of-the-box, however, it is not fully customizable. If the out-of-box integration does not fully meet your needs, you may end up needing to build your own enhanced integration, re-creating the functionality included in the out-of-box integration. + +### Objective + +This `salesforce-integration` feature aims to be used as a starting point for your own customized Salesforce integration. The feature offers largely the same baseline functionality of the out-of-box integration, as well as some critical usability enhancements: + +- Disables the pop-out and pop-in buttons while on a call, to prevent accidental call hangups +- When screen pop returns multiple records, a dropdown is added to the interface for the agent to select the appropriate record for activity logging + +### Configuration options + +The feature is functional only when Flex is embedded within Salesforce as described [in the Flex documentation](https://www.twilio.com/docs/flex/admin-guide/integrations/salesforce). If the out-of-box Salesforce integration has been [enabled within the Twilio Console](https://console.twilio.com/us1/develop/flex/settings/integrations/salesforce), it must first be disabled. + +To enable the feature, under the `flex-config` attributes set the `salesforce_integration` `enabled` flag to `true`. + +```json +"salesforce_integration": { + "enabled": true, + "activity_logging": true, // Enables the automatic creation of activity records when a task is completed or canceled + "click_to_dial": true, // Enables handling click-to-dial within Salesforce + "copilot_notes": true, // Adds agent copilot disposition and summary to activity records created by the feature + "hide_crm_container": true, // Hides the Flex CRM container when embedded within Salesforce + "prevent_popout_during_call": true, // Disables the pop-out or pop-in button while on a call, to prevent accidental hangups + "screen_pop": true // Enables search and screen pop of Salesforce records based on the inbound task attributes +} +``` + +#### Screen pop attributes + +When an inbound task is accepted, and the `screen_pop` configuration option is set to `true`, the feature will use task attributes in conjunction with the configured [Salesforce softphone layout](https://help.salesforce.com/s/articleView?id=service.cti_admin_phonelayouts.htm&type=5) to determine what record or page is displayed to the agent. Task attributes are used as follows: + +1. If the `sfdcObjectId` attribute is present, the Salesforce record ID contained within the attribute will be popped. No other attributes will be used for screen pop when this attribute is present. + 1. This can be useful when you are performing a data dip to find a record as part of the IVR and want to pop the same record. +1. Otherwise, a search within Salesforce will be performed, per the softphone layout, using the following task attributes in the following order: + 1. `name` + 1. `from` + 1. `identity` + 1. `customerAddress` + +## Technical Details + +The integration uses the [Salesforce Open CTI APIs](https://developer.salesforce.com/docs/atlas.en-us.api_cti.meta/api_cti/sforce_api_cti_intro.htm) and the [Lightning Console API](https://developer.salesforce.com/docs/atlas.en-us.api_console.meta/api_console/sforce_api_console_js_getting_started.htm) to communicate with the Salesforce instance Flex is embedded within. + +### Initialization + +**File: `utils/SfdcLoader.ts`** + +**Flex hook: `pluginsInitialized` event** + +Before any integration functionality can be realized, we must first load the appropriate JS libraries from Salesforce. To do so, we load the Open CTI and Console API JS libraries from the Salesforce domain we are embedded within by inserting them as `