diff --git a/lambdas/account-scoped/src/hrm/conversationMedia.ts b/lambdas/account-scoped/src/hrm/conversationMedia.ts new file mode 100644 index 0000000000..4b3006b16d --- /dev/null +++ b/lambdas/account-scoped/src/hrm/conversationMedia.ts @@ -0,0 +1,165 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import type { AccountSID, CallSid } from '@tech-matters/twilio-types'; +import { HrmContact } from '@tech-matters/hrm-types'; +import { isOk, Result } from '../Result'; +import { getExternalRecordingS3Location } from '../conversation/getExternalRecordingS3Location'; +import { getDocsBucketName } from '@tech-matters/twilio-configuration'; +import { postToInternalHrmEndpoint } from './internalHrmRequest'; +import { HrmAccountId } from './hrmAccountId'; + +export type S3Location = { + bucket: string; + key: string; +}; + +export type TwilioStoredMedia = { + storeType: 'twilio'; + storeTypeSpecificData: { + reservationSid: string; + }; +}; + +export type S3StoredMedia = { + storeType: 'S3'; + storeTypeSpecificData: { + type: 'transcript' | 'recording'; + location?: S3Location; + }; +}; + +export type ConversationMedia = TwilioStoredMedia | S3StoredMedia; + +export const newTwilioStoredMedia = (reservationSid: string): TwilioStoredMedia => ({ + storeType: 'twilio', + storeTypeSpecificData: { + reservationSid, + }, +}); + +export const newPendingS3StoredTranscript = (): S3StoredMedia => ({ + storeType: 'S3', + storeTypeSpecificData: { + type: 'transcript', + location: undefined, + }, +}); + +export const newS3StoredRecording = (location: S3Location): S3StoredMedia => ({ + storeType: 'S3', + storeTypeSpecificData: { + type: 'recording', + location, + }, +}); + +/** + * Looks up the S3 location the external recording for a call will be written to and returns a + * conversation media item pointing at it, or undefined if no recording could be located. + */ +export const newS3StoredRecordingForCall = async ({ + accountSid, + callSid, +}: { + accountSid: AccountSID; + callSid: CallSid | string; +}): Promise => { + const recordingResult = await getExternalRecordingS3Location({ accountSid, callSid }); + if (!isOk(recordingResult)) { + console.warn( + `[${accountSid}] Could not find an external recording location for call ${callSid}, no recording conversation media will be added`, + recordingResult.message, + ); + return undefined; + } + const { bucket, key } = recordingResult.data; + return newS3StoredRecording({ bucket, key }); +}; + +/** + * Returns the recording conversation media for a voice task, using the recording location already + * attached to the task attributes if there is one, otherwise looking it up from the call. + */ +export const newS3StoredRecordingForVoiceTask = async ({ + accountSid, + taskAttributes, +}: { + accountSid: AccountSID; + taskAttributes: { + conference?: { participants?: { worker?: CallSid } }; + conversations?: { segment_link?: string }; + }; +}): Promise => { + const { conference, conversations } = taskAttributes; + const segmentLink = conversations?.segment_link; + if (segmentLink) { + // The recording location is already added to the task, no need to look it up + const { pathname } = new URL(segmentLink); + return newS3StoredRecording({ + bucket: await getDocsBucketName(accountSid), + key: pathname.startsWith('/') ? pathname.substring(1) : pathname, + }); + } + const callSid = conference?.participants?.worker; + if (!callSid) { + console.warn( + `[${accountSid}] Could not find a call sid for the worker in the conference attached to the task, no recording conversation media will be added`, + ); + return undefined; + } + return newS3StoredRecordingForCall({ accountSid, callSid }); +}; + +export const saveConversationMedia = async ({ + hrmAccountId, + hrmApiVersion, + contactId, + conversationMedia, +}: { + hrmAccountId: HrmAccountId; + hrmApiVersion: string; + contactId: string | number; + conversationMedia: ConversationMedia[]; +}): Promise | undefined> => { + if (!conversationMedia.length) { + console.debug( + `[${hrmAccountId}] No conversation media to add to contact ${contactId}, skipping`, + ); + return undefined; + } + const conversationMediaResult = await postToInternalHrmEndpoint< + ConversationMedia[], + HrmContact + >( + hrmAccountId, + hrmApiVersion, + `contacts/${contactId}/conversationMedia`, + conversationMedia, + ); + if (isOk(conversationMediaResult)) { + console.info( + `[${hrmAccountId}] Added ${conversationMedia.length} conversation media item(s) to contact ${contactId}`, + ); + } else { + console.error( + `[${hrmAccountId}] Failed to add conversation media to contact ${contactId}`, + conversationMediaResult.message, + conversationMediaResult.error, + ); + } + return conversationMediaResult; +}; diff --git a/lambdas/account-scoped/src/hrm/conversationMediaTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/conversationMediaTaskRouterListener.ts new file mode 100644 index 0000000000..edd0c56b67 --- /dev/null +++ b/lambdas/account-scoped/src/hrm/conversationMediaTaskRouterListener.ts @@ -0,0 +1,176 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import type { EventFields } from '../taskrouter'; +import { AccountSID, channelTypes, ChannelType } from '@tech-matters/twilio-types'; +import { registerTaskRouterEventHandler } from '../taskrouter/taskrouterEventHandler'; +import { TASK_COMPLETED } from '../taskrouter/eventTypes'; +import { Twilio } from 'twilio'; +import { getWorkspaceSid } from '@tech-matters/twilio-configuration'; +import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; +import { inferHrmAccountId } from './hrmAccountId'; +import { + ConversationMedia, + newPendingS3StoredTranscript, + newS3StoredRecordingForVoiceTask, + newTwilioStoredMedia, + saveConversationMedia, +} from './conversationMedia'; + +const CHAT_CHANNEL_TYPES: string[] = [ + channelTypes.WEB, + channelTypes.CHAT, + channelTypes.SMS, + channelTypes.WHATSAPP, + channelTypes.MESSENGER, + channelTypes.INSTAGRAM, + channelTypes.LINE, + channelTypes.MODICA, + channelTypes.TELEGRAM, +]; + +const isChatChannel = (channel?: ChannelType | string): boolean => + Boolean(channel && CHAT_CHANNEL_TYPES.includes(channel)); + +const isVoiceChannel = (channel?: ChannelType | string): boolean => + channel === channelTypes.VOICE; + +/** + * Finds the sid of the reservation that currently has control of the task, so it can be used to + * look up the conversation in the Twilio Insights overlay. + */ +const findReservationSidWithTaskControl = async ( + accountSid: AccountSID, + client: Twilio, + taskSid: string, + taskAttributes: Record, +): Promise => { + const sidWithTaskControl = taskAttributes?.transferMeta?.sidWithTaskControl; + if (sidWithTaskControl) { + return sidWithTaskControl; + } + try { + const reservations = await client.taskrouter.v1.workspaces + .get(await getWorkspaceSid(accountSid)) + .tasks.get(taskSid) + .reservations.list(); + const activeReservation = + reservations.find(r => + ['wrapping', 'accepted', 'completed'].includes(r.reservationStatus), + ) ?? reservations[0]; + return activeReservation?.sid; + } catch (error) { + console.error( + `[${accountSid}] Failed to look up reservations for task ${taskSid}, no Twilio stored conversation media will be added`, + error, + ); + return undefined; + } +}; + +export const handleEvent = async ( + { + TaskAttributes: taskAttributesString, + TaskSid: taskSid, + WorkerName: workerName, + }: EventFields, + accountSid: AccountSID, + client: Twilio, +): Promise => { + const serviceConfigurationAttributes = + await retrieveServiceConfigurationAttributes(client); + const { + hrm_api_version: hrmApiVersion, + feature_flags: { + use_twilio_lambda_for_conversation_media: useTwilioLambdaForConversationMedia, + }, + } = serviceConfigurationAttributes; + + if (!useTwilioLambdaForConversationMedia) { + console.debug( + `use_twilio_lambda_for_conversation_media is not set, the conversation media for the contact associated with task ${taskSid} will be created in Flex.`, + ); + return; + } + + const taskAttributes = taskAttributesString ? JSON.parse(taskAttributesString) : {}; + const { contactId, channelType, customChannelType } = taskAttributes; + + if (!contactId) { + console.debug( + `No contactId set on task ${taskSid}, cannot add conversation media to a contact.`, + ); + return; + } + + const channel = customChannelType || channelType; + + if (channel === channelTypes.VOICEMAIL) { + console.debug( + `Task ${taskSid} is a voicemail task, its conversation media is added when the voicemail contact is created.`, + ); + return; + } + + const enforceZeroTranscriptRetention = Boolean( + serviceConfigurationAttributes.enforceZeroTranscriptRetention, + ); + const externalRecordingsEnabled = Boolean( + serviceConfigurationAttributes.external_recordings_enabled, + ); + + const isChatTask = isChatChannel(channel); + const isVoiceTask = isVoiceChannel(channel); + const retainTranscript = isChatTask && !enforceZeroTranscriptRetention; + + const conversationMedia: ConversationMedia[] = []; + + if (retainTranscript) { + conversationMedia.push(newPendingS3StoredTranscript()); + } + + if (retainTranscript || isVoiceTask) { + // Store reservation sid to use Twilio insights overlay (recordings/transcript) + const reservationSid = await findReservationSidWithTaskControl( + accountSid, + client, + taskSid, + taskAttributes, + ); + if (reservationSid) { + conversationMedia.push(newTwilioStoredMedia(reservationSid)); + } + } + + if (isVoiceTask && externalRecordingsEnabled) { + const recordingMedia = await newS3StoredRecordingForVoiceTask({ + accountSid, + taskAttributes, + }); + if (recordingMedia) { + conversationMedia.push(recordingMedia); + } + } + + await saveConversationMedia({ + hrmAccountId: inferHrmAccountId(accountSid, workerName), + hrmApiVersion, + contactId, + conversationMedia, + }); +}; + +registerTaskRouterEventHandler([TASK_COMPLETED], handleEvent); diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index 19eb246af4..b0e9fad16f 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -34,7 +34,7 @@ import { HrmContact } from '@tech-matters/hrm-types'; import { populateHrmContactFormFromTaskByMappings } from './populateHrmContactFormFromTaskByMappings'; import { parseISO } from 'date-fns/parseISO'; import { HttpClientError } from '../httpErrors'; -import { getExternalRecordingS3Location } from '../conversation/getExternalRecordingS3Location'; +import { newS3StoredRecordingForCall, saveConversationMedia } from './conversationMedia'; import { patchTaskAttributes } from '../task/patchTaskAttributes'; // Temporarily copied to this repo, will share the flex types when we move them into the same repo @@ -260,37 +260,18 @@ export const handleEvent = async ( console.info( `Channel type is ${channel}, adding conversation media with call sid ${taskAttributes.callSid}`, ); - const recordingResult = await getExternalRecordingS3Location({ + const recordingMedia = await newS3StoredRecordingForCall({ accountSid, callSid: taskAttributes.callSid, }); - if (isOk(recordingResult)) { - const conversationMedia = [ - { - storeType: 'S3', - storeTypeSpecificData: { - type: 'recording', - location: { - bucket: recordingResult.data.bucket, - key: recordingResult.data.key, - }, - }, - }, - ]; - - const conversationMediaResult = await postToInternalHrmEndpoint< - HrmContact['conversationMedia'], - HrmContact - >( + if (recordingMedia) { + await saveConversationMedia({ hrmAccountId, hrmApiVersion, - `contacts/${id}/conversationMedia`, - conversationMedia, - ); - console.debug( - `Conversation media result ${conversationMediaResult.status} ${isOk(conversationMediaResult) ? JSON.stringify(conversationMediaResult.data) : JSON.stringify(conversationMediaResult.error)}`, - ); + contactId: id, + conversationMedia: [recordingMedia], + }); } } }; diff --git a/lambdas/account-scoped/src/taskrouter/index.ts b/lambdas/account-scoped/src/taskrouter/index.ts index 2ac7f603f1..aec7b7aab8 100644 --- a/lambdas/account-scoped/src/taskrouter/index.ts +++ b/lambdas/account-scoped/src/taskrouter/index.ts @@ -17,6 +17,7 @@ import '../hrm/createHrmContactTaskRouterListener'; import '../hrm/addHangupByTaskRouterListener'; import '../hrm/conversationDurationTaskRouterListener'; +import '../hrm/conversationMediaTaskRouterListener'; import '../task/addCustomerExternalIdTaskRouterListener'; import '../task/addInitialHangUpByTaskRouterListener'; import '../conversation/addTaskSidToChannelAttributesTaskRouterListener'; diff --git a/lambdas/account-scoped/tests/unit/hrm/conversationMediaTaskRouterListener.test.ts b/lambdas/account-scoped/tests/unit/hrm/conversationMediaTaskRouterListener.test.ts new file mode 100644 index 0000000000..f37059ccac --- /dev/null +++ b/lambdas/account-scoped/tests/unit/hrm/conversationMediaTaskRouterListener.test.ts @@ -0,0 +1,320 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import twilio from 'twilio'; +import { RecursivePartial } from '../RecursivePartial'; +import { WorkspaceContext } from 'twilio/lib/rest/taskrouter/v1/workspace'; +import { EventFields } from '../../../src/taskrouter'; +import { getSsmParameter } from '@tech-matters/ssm-cache'; +import { handleEvent } from '../../../src/hrm/conversationMediaTaskRouterListener'; +import { getExternalRecordingS3Location } from '../../../src/conversation/getExternalRecordingS3Location'; +import { + TEST_ACCOUNT_SID, + TEST_CONTACT_ID, + TEST_RESERVATION_FOR_TEST_WORKER_ON_TEST_TASK_SID, + TEST_TASK_SID, + TEST_WORKER_SID, + TEST_WORKSPACE_SID, +} from '../../testTwilioValues'; +import { setConfigurationAttributes } from '../mockServiceConfiguration'; +import { newErr, newOk } from '../../../src/Result'; + +const mockFetch: jest.MockedFunction = jest.fn(); +global.fetch = mockFetch; + +jest.mock('@tech-matters/ssm-cache', () => ({ + getSsmParameter: jest.fn(), +})); +const mockGetSsmParameter = getSsmParameter as jest.MockedFunction< + typeof getSsmParameter +>; + +jest.mock('../../../src/conversation/getExternalRecordingS3Location', () => ({ + getExternalRecordingS3Location: jest.fn(), +})); +const mockGetExternalRecordingS3Location = + getExternalRecordingS3Location as jest.MockedFunction< + typeof getExternalRecordingS3Location + >; + +const newEventFields = (attributes: Record = {}): EventFields => + ({ + TaskAttributes: JSON.stringify({ + channelSid: 'CHut', + channelType: 'web', + contactId: TEST_CONTACT_ID, + ...attributes, + }), + TaskSid: TEST_TASK_SID, + WorkerSid: TEST_WORKER_SID, + }) as EventFields; + +const postedConversationMedia = () => + mockFetch.mock.calls + .filter(([url]) => url.toString().endsWith('conversationMedia')) + .map(([, options]) => JSON.parse(options?.body as string)); + +describe('conversationMediaTaskRouterListener handleEvent', () => { + let twilioClient: twilio.Twilio; + + const setUpClient = ( + attributes: Record = { + feature_flags: { use_twilio_lambda_for_conversation_media: true }, + }, + ) => { + const mockTwilioClient: RecursivePartial = { + taskrouter: { + v1: { + workspaces: { + get: (workspaceSid: string) => { + if (workspaceSid === TEST_WORKSPACE_SID) { + return { + tasks: { + get: (taskSid: string) => { + if (taskSid === TEST_TASK_SID) { + return { + reservations: { + list: async () => [ + { + sid: TEST_RESERVATION_FOR_TEST_WORKER_ON_TEST_TASK_SID, + reservationStatus: 'wrapping', + workerSid: TEST_WORKER_SID, + }, + ], + }, + }; + } else throw new Error(`Unexpected task SID: ${taskSid}`); + }, + }, + } as WorkspaceContext; + } else throw new Error(`Unexpected workspace SID: ${workspaceSid}`); + }, + }, + }, + }, + }; + twilioClient = setConfigurationAttributes( + mockTwilioClient as twilio.Twilio, + attributes, + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetSsmParameter.mockImplementation((path: string) => { + if (path.includes('/static_key')) { + return Promise.resolve('unit_test_static_key'); + } else if (path.endsWith('/workspace_sid')) { + return Promise.resolve(TEST_WORKSPACE_SID); + } + throw new Error(`Unexpected SSM parameter path: ${path}`); + }); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response); + mockGetExternalRecordingS3Location.mockResolvedValue( + newOk({ recordingSid: 'REut', bucket: 'ut-bucket', key: 'ut-key' }), + ); + setUpClient(); + }); + + test('feature flag not set - does nothing', async () => { + setUpClient({ feature_flags: { use_twilio_lambda_for_conversation_media: false } }); + await handleEvent(newEventFields(), TEST_ACCOUNT_SID, twilioClient); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('no contactId on task - does nothing', async () => { + await handleEvent( + newEventFields({ contactId: undefined }), + TEST_ACCOUNT_SID, + twilioClient, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('voicemail task - does nothing, media added on contact creation', async () => { + await handleEvent( + newEventFields({ channelType: 'voicemail' }), + TEST_ACCOUNT_SID, + twilioClient, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('chat task - adds pending transcript and twilio stored media', async () => { + await handleEvent(newEventFields(), TEST_ACCOUNT_SID, twilioClient); + expect(postedConversationMedia()).toEqual([ + [ + { + storeType: 'S3', + storeTypeSpecificData: { type: 'transcript' }, + }, + { + storeType: 'twilio', + storeTypeSpecificData: { + reservationSid: TEST_RESERVATION_FOR_TEST_WORKER_ON_TEST_TASK_SID, + }, + }, + ], + ]); + }); + + test('chat task with zero transcript retention - adds no media', async () => { + setUpClient({ + feature_flags: { use_twilio_lambda_for_conversation_media: true }, + enforceZeroTranscriptRetention: true, + }); + await handleEvent(newEventFields(), TEST_ACCOUNT_SID, twilioClient); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('chat task uses reservation with task control if a transfer took place', async () => { + await handleEvent( + newEventFields({ + transferMeta: { sidWithTaskControl: 'WR-transferred-to' }, + }), + TEST_ACCOUNT_SID, + twilioClient, + ); + expect(postedConversationMedia()[0]).toContainEqual({ + storeType: 'twilio', + storeTypeSpecificData: { reservationSid: 'WR-transferred-to' }, + }); + }); + + test('voice task with external recordings enabled - adds twilio media & looked up recording', async () => { + setUpClient({ + feature_flags: { use_twilio_lambda_for_conversation_media: true }, + external_recordings_enabled: true, + }); + await handleEvent( + newEventFields({ + channelType: 'voice', + conference: { participants: { worker: 'CAut' } }, + }), + TEST_ACCOUNT_SID, + twilioClient, + ); + expect(mockGetExternalRecordingS3Location).toHaveBeenCalledWith({ + accountSid: TEST_ACCOUNT_SID, + callSid: 'CAut', + }); + expect(postedConversationMedia()).toEqual([ + [ + { + storeType: 'twilio', + storeTypeSpecificData: { + reservationSid: TEST_RESERVATION_FOR_TEST_WORKER_ON_TEST_TASK_SID, + }, + }, + { + storeType: 'S3', + storeTypeSpecificData: { + type: 'recording', + location: { bucket: 'ut-bucket', key: 'ut-key' }, + }, + }, + ], + ]); + }); + + test('voice task with segment link - uses the location on the task rather than looking it up', async () => { + setUpClient({ + feature_flags: { use_twilio_lambda_for_conversation_media: true }, + external_recordings_enabled: true, + }); + mockGetSsmParameter.mockImplementation((path: string) => { + if (path.includes('/static_key')) { + return Promise.resolve('unit_test_static_key'); + } else if (path.endsWith('/workspace_sid')) { + return Promise.resolve(TEST_WORKSPACE_SID); + } else if (path.endsWith('/docs_bucket_name')) { + return Promise.resolve('ut-docs-bucket'); + } + throw new Error(`Unexpected SSM parameter path: ${path}`); + }); + await handleEvent( + newEventFields({ + channelType: 'voice', + conversations: { + segment_link: 'https://recordings.example.com/voice-recordings/ACut/REut', + }, + }), + TEST_ACCOUNT_SID, + twilioClient, + ); + expect(mockGetExternalRecordingS3Location).not.toHaveBeenCalled(); + expect(postedConversationMedia()[0]).toContainEqual({ + storeType: 'S3', + storeTypeSpecificData: { + type: 'recording', + location: { bucket: 'ut-docs-bucket', key: 'voice-recordings/ACut/REut' }, + }, + }); + }); + + test('voice task where recording cannot be found - only adds twilio stored media', async () => { + setUpClient({ + feature_flags: { use_twilio_lambda_for_conversation_media: true }, + external_recordings_enabled: true, + }); + mockGetExternalRecordingS3Location.mockResolvedValue( + newErr({ message: 'No recording found', error: { statusCode: 404 } }), + ); + await handleEvent( + newEventFields({ + channelType: 'voice', + conference: { participants: { worker: 'CAut' } }, + }), + TEST_ACCOUNT_SID, + twilioClient, + ); + expect(postedConversationMedia()).toEqual([ + [ + { + storeType: 'twilio', + storeTypeSpecificData: { + reservationSid: TEST_RESERVATION_FOR_TEST_WORKER_ON_TEST_TASK_SID, + }, + }, + ], + ]); + }); + + test('voice task with external recordings disabled - only adds twilio stored media', async () => { + await handleEvent( + newEventFields({ + channelType: 'voice', + conference: { participants: { worker: 'CAut' } }, + }), + TEST_ACCOUNT_SID, + twilioClient, + ); + expect(mockGetExternalRecordingS3Location).not.toHaveBeenCalled(); + expect(postedConversationMedia()).toEqual([ + [ + { + storeType: 'twilio', + storeTypeSpecificData: { + reservationSid: TEST_RESERVATION_FOR_TEST_WORKER_ON_TEST_TASK_SID, + }, + }, + ], + ]); + }); +}); diff --git a/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts b/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts index bda8a84deb..fed4abea76 100644 --- a/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts +++ b/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts @@ -163,7 +163,11 @@ describe('handleEvent', () => { ); mockPatchTaskAttributes.mockResolvedValue(newOk(undefined)); mockGetExternalRecordingS3Location.mockResolvedValue( - newOk({ recordingSid: 'REtest', key: 'voice-recordings/ACut/REtest', bucket: 'test-bucket' }), + newOk({ + recordingSid: 'REtest', + key: 'voice-recordings/ACut/REtest', + bucket: 'test-bucket', + }), ); }); @@ -215,7 +219,11 @@ describe('handleEvent', () => { test('voicemail task - creates contact and posts conversationMedia with S3 recording location', async () => { const eventFields: EventFields = { - ...newEventFields({ channelType: 'voicemail', customChannelType: 'voicemail', callSid: 'CAtest456' }), + ...newEventFields({ + channelType: 'voicemail', + customChannelType: 'voicemail', + callSid: 'CAtest456', + }), } as EventFields; setTaskReturnedByFetch(eventFields); @@ -229,7 +237,9 @@ describe('handleEvent', () => { // Should have called fetch twice: once for the contact creation, once for conversationMedia expect(mockFetch).toHaveBeenCalledTimes(2); const conversationMediaCall = mockFetch.mock.calls[1]; - const conversationMediaBody = JSON.parse((conversationMediaCall[1] as RequestInit).body as string); + const conversationMediaBody = JSON.parse( + (conversationMediaCall[1] as RequestInit).body as string, + ); expect(conversationMediaBody).toEqual([ { storeType: 'S3', @@ -250,7 +260,11 @@ describe('handleEvent', () => { ); const eventFields: EventFields = { - ...newEventFields({ channelType: 'voicemail', customChannelType: 'voicemail', callSid: 'CAtest456' }), + ...newEventFields({ + channelType: 'voicemail', + customChannelType: 'voicemail', + callSid: 'CAtest456', + }), } as EventFields; setTaskReturnedByFetch(eventFields); @@ -262,7 +276,10 @@ describe('handleEvent', () => { }); test('non-voicemail task - does not look up recording or post conversationMedia', async () => { - const eventFields = newEventFields({ channelType: 'voice', customChannelType: 'voice' }); + const eventFields = newEventFields({ + channelType: 'voice', + customChannelType: 'voice', + }); setTaskReturnedByFetch(eventFields); await handleEvent(eventFields, TEST_ACCOUNT_SID, twilioClient); diff --git a/lambdas/account-scoped/tests/unit/voicemail/recordingCompleteCallback.test.ts b/lambdas/account-scoped/tests/unit/voicemail/recordingCompleteCallback.test.ts index b1c32a044f..1681c1385f 100644 --- a/lambdas/account-scoped/tests/unit/voicemail/recordingCompleteCallback.test.ts +++ b/lambdas/account-scoped/tests/unit/voicemail/recordingCompleteCallback.test.ts @@ -26,8 +26,12 @@ jest.mock('@tech-matters/twilio-configuration', () => ({ getWorkspaceSid: jest.fn(), })); -const mockGetTwilioClient = getTwilioClient as jest.MockedFunction; -const mockGetWorkspaceSid = getWorkspaceSid as jest.MockedFunction; +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +const mockGetWorkspaceSid = getWorkspaceSid as jest.MockedFunction< + typeof getWorkspaceSid +>; const TEST_CALL_SID = 'CAtest123'; const TEST_RECORDING_SID = 'REtest123'; @@ -87,7 +91,10 @@ describe('recordingCompleteCallback', () => { }); test('returns missing parameter error when from is absent', async () => { - const request = createRequest({ callSid: TEST_CALL_SID, recordingSid: TEST_RECORDING_SID }); + const request = createRequest({ + callSid: TEST_CALL_SID, + recordingSid: TEST_RECORDING_SID, + }); const result = await recordingCompleteCallback(request, TEST_ACCOUNT_SID); expect(isErr(result)).toBe(true); if (isErr(result)) { diff --git a/plugin-hrm-form/src/___tests__/services/ContactService.test.ts b/plugin-hrm-form/src/___tests__/services/ContactService.test.ts index f1004a4342..9cabddf621 100644 --- a/plugin-hrm-form/src/___tests__/services/ContactService.test.ts +++ b/plugin-hrm-form/src/___tests__/services/ContactService.test.ts @@ -27,12 +27,13 @@ import { updateContactInHrm, } from '../../services/ContactService'; import { channelTypes } from '../../states/DomainConstants'; -import { getDefinitionVersions, getHrmConfig } from '../../hrmConfig'; +import { getAseloFeatureFlags, getDefinitionVersions, getHrmConfig } from '../../hrmConfig'; import { VALID_EMPTY_CONTACT, VALID_EMPTY_METADATA } from '../testContacts'; import { ContactState } from '../../states/contacts/existingContacts'; const helpline = 'ChildLine'; const mockGetHrmConfig = getHrmConfig as jest.Mock; +const mockGetAseloFeatureFlags = getAseloFeatureFlags as jest.Mock; // eslint-disable-next-line no-empty-function global.fetch = global.fetch ? global.fetch : () => Promise.resolve({ ok: true }); @@ -324,6 +325,36 @@ describe('finalizeContact() (externalRecording)', () => { }, ]); }); + + test('should not send conversationMedia when use_twilio_lambda_for_conversation_media is enabled', async () => { + mockGetAseloFeatureFlags.mockReturnValue({ + ...mockBaseConfig.featureFlags, + // eslint-disable-next-line camelcase + use_twilio_lambda_for_conversation_media: true, + }); + try { + const task = { + taskSid: 'taskSid', + channelType: channelTypes.voice, + attributes: { + conference: { + participants: { + worker: { + callSid: 'callSid', + }, + }, + }, + }, + }; + + const { savedContact } = createContactState({ callType: callTypes.child, childFirstName: 'Jill' }); + await finalizeContact(task, savedContact); + + expect(mockedFetch.mock.calls.filter(([url]) => url.toString().endsWith('conversationMedia'))).toHaveLength(0); + } finally { + mockGetAseloFeatureFlags.mockReturnValue(mockBaseConfig.featureFlags); + } + }); }); test('updateContactInHrm - calls a PATCH HRM endpoint using the supplied contact ID in the route', async () => { diff --git a/plugin-hrm-form/src/services/ContactService.ts b/plugin-hrm-form/src/services/ContactService.ts index 3c1d61569f..15c07fe9de 100644 --- a/plugin-hrm-form/src/services/ContactService.ts +++ b/plugin-hrm-form/src/services/ContactService.ts @@ -257,8 +257,10 @@ export const finalizeContact = async ( reservationSid?: string | undefined, ): Promise => { try { - const twilioTaskResult = await determineConversationMedia(task, contact, reservationSid); - await saveConversationMedia(contact.id, twilioTaskResult.conversationMedia); + if (!getAseloFeatureFlags().use_twilio_lambda_for_conversation_media) { + const twilioTaskResult = await determineConversationMedia(task, contact, reservationSid); + await saveConversationMedia(contact.id, twilioTaskResult.conversationMedia); + } return await updateContactInHrm(contact.id, {}, true); } catch (error) { console.error('Error finalizing contact:', error); diff --git a/plugin-hrm-form/src/types/FeatureFlags.ts b/plugin-hrm-form/src/types/FeatureFlags.ts index 929dd7362c..0f699ffa40 100644 --- a/plugin-hrm-form/src/types/FeatureFlags.ts +++ b/plugin-hrm-form/src/types/FeatureFlags.ts @@ -47,6 +47,7 @@ export type FeatureFlags = { use_prepopulate_mappings: boolean; // Use PrepopulateMappings.json instead of PrepopulateKeys.json use_twilio_lambda_for_conference_functions: boolean; // Use the twilio account scoped lambda for conferencing functions use_twilio_lambda_for_conversation_duration: boolean; // Use the twilio account scoped lambda to calculate conversationDuration + use_twilio_lambda_for_conversation_media: boolean; // Use the twilio account scoped lambda to create conversationMedia records use_twilio_lambda_for_iwf_reporting: boolean; // Use the twilio account scoped lambda for reportToIWF and selfReportToIWF use_twilio_lambda_for_offline_contact_tasks: boolean; // Use the twilio account scoped lambda for assignOfflineContactInit and assignOfflineContactResolve use_twilio_lambda_for_recordings_lookup: boolean; // Use the twilio account scoped lambda for getMediaUrl and getExternalRecordingS3Location