diff --git a/lambdas/account-scoped/src/conversation/getExternalRecordingS3Location.ts b/lambdas/account-scoped/src/conversation/getExternalRecordingS3Location.ts new file mode 100644 index 0000000000..9401d34854 --- /dev/null +++ b/lambdas/account-scoped/src/conversation/getExternalRecordingS3Location.ts @@ -0,0 +1,58 @@ +/** + * Copyright (C) 2021-2023 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 { FlexValidatedHandler } from '../validation/flexToken'; +import type { AccountSID } from '@tech-matters/twilio-types'; +import { newErr, newOk } from '../Result'; +import { getTwilioClient, getDocsBucketName } from '@tech-matters/twilio-configuration'; +import { newMissingParameterResult } from '../httpErrors'; + +export const getExternalRecordingS3LocationHandler: FlexValidatedHandler = async ( + { body: event }, + accountSid: AccountSID, +) => { + const { callSid } = event as { callSid?: string }; + + if (!callSid) { + return newMissingParameterResult('callSid'); + } + + try { + const client = await getTwilioClient(accountSid); + const bucket = await getDocsBucketName(accountSid); + + const recordings = await client.recordings.list({ callSid, limit: 20 }); + + if (recordings.length === 0) { + return newErr({ + message: 'No recording found', + error: { statusCode: 404 }, + }); + } + if (recordings.length > 1) { + return newErr({ + message: 'More than one recording found', + error: { statusCode: 409 }, + }); + } + + const recordingSid = recordings[0].sid; + const key = `voice-recordings/${accountSid}/${recordingSid}`; + return newOk({ recordingSid, key, bucket }); + } catch (err: any) { + return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); + } +}; diff --git a/lambdas/account-scoped/src/conversation/getMediaUrl.ts b/lambdas/account-scoped/src/conversation/getMediaUrl.ts new file mode 100644 index 0000000000..9b7d3aca04 --- /dev/null +++ b/lambdas/account-scoped/src/conversation/getMediaUrl.ts @@ -0,0 +1,64 @@ +/** + * Copyright (C) 2021-2023 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 { FlexValidatedHandler } from '../validation/flexToken'; +import type { AccountSID } from '@tech-matters/twilio-types'; +import { newErr, newOk } from '../Result'; +import { getAccountAuthToken } from '@tech-matters/twilio-configuration'; +import { newMissingParameterResult } from '../httpErrors'; + +const TWILIO_MCS_BASE_URL = 'https://mcs.us1.twilio.com/v1/Services'; + +export const getMediaUrlHandler: FlexValidatedHandler = async ( + { body: event }, + accountSid: AccountSID, +) => { + const { serviceSid, mediaSid } = event as { serviceSid?: string; mediaSid?: string }; + + if (!serviceSid) return newMissingParameterResult('serviceSid'); + if (!mediaSid) return newMissingParameterResult('mediaSid'); + + try { + const authToken = await getAccountAuthToken(accountSid); + const url = `${TWILIO_MCS_BASE_URL}/${serviceSid}/Media/${mediaSid}`; + const base64Credentials = Buffer.from(`${accountSid}:${authToken}`).toString( + 'base64', + ); + + const responseData = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Basic ${base64Credentials}`, + }, + }); + + const media = (await responseData.json()) as { + status?: number; + links?: { content_direct_temporary?: string }; + }; + + if (!responseData.ok) { + return newErr({ + message: `Failed to fetch media URL: ${responseData.statusText}`, + error: { statusCode: media.status ?? responseData.status }, + }); + } + + return newOk(media.links?.content_direct_temporary); + } catch (err: any) { + return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); + } +}; diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 416d93853a..7737007b16 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -70,6 +70,8 @@ import { pullTaskHandler } from './worker/pullTask'; import { sendSystemMessageHandler } from './conversation/sendSystemMessage'; import { sendStudioMessageHandler } from './conversation/sendStudioMessage'; import { sendMessageAndRunJanitorHandler } from './conversation/sendMessageAndRunJanitor'; +import { getExternalRecordingS3LocationHandler } from './conversation/getExternalRecordingS3Location'; +import { getMediaUrlHandler } from './conversation/getMediaUrl'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -231,6 +233,14 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], handler: selfReportToIWFHandler, }, + 'conversation/getExternalRecordingS3Location': { + requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + handler: getExternalRecordingS3LocationHandler, + }, + 'conversation/getMediaUrl': { + requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + handler: getMediaUrlHandler, + }, 'worker/populateCounselors': { requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], handler: populateCounselorsHandler, diff --git a/lambdas/account-scoped/tests/unit/conversation/getExternalRecordingS3Location.test.ts b/lambdas/account-scoped/tests/unit/conversation/getExternalRecordingS3Location.test.ts new file mode 100644 index 0000000000..aaae27571e --- /dev/null +++ b/lambdas/account-scoped/tests/unit/conversation/getExternalRecordingS3Location.test.ts @@ -0,0 +1,139 @@ +/** + * Copyright (C) 2021-2023 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 { getExternalRecordingS3LocationHandler } from '../../../src/conversation/getExternalRecordingS3Location'; +import { getTwilioClient, getDocsBucketName } from '@tech-matters/twilio-configuration'; +import { isErr, isOk } from '../../../src/Result'; +import { FlexValidatedHttpRequest } from '../../../src/validation/flexToken'; +import { TEST_ACCOUNT_SID } from '../../testTwilioValues'; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), + getDocsBucketName: jest.fn(), +})); + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +const mockGetDocsBucketName = getDocsBucketName as jest.MockedFunction< + typeof getDocsBucketName +>; + +const TEST_BUCKET = 'test-docs-bucket'; +const TEST_CALL_SID = 'CA00000000000000000000000000000000'; +const TEST_RECORDING_SID = 'RE00000000000000000000000000000000'; + +const createMockRequest = (body: any): FlexValidatedHttpRequest => ({ + method: 'POST', + headers: {}, + path: '/test', + query: {}, + body, + tokenResult: { worker_sid: 'WK1234', roles: ['agent'] }, +}); + +describe('getExternalRecordingS3LocationHandler', () => { + let mockClient: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDocsBucketName.mockResolvedValue(TEST_BUCKET); + mockClient = { + recordings: { + list: jest.fn(), + }, + }; + mockGetTwilioClient.mockResolvedValue(mockClient as any); + }); + + it('should return 400 when callSid is missing', async () => { + const request = createMockRequest({}); + + const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toContain('callSid'); + expect(result.error.statusCode).toBe(400); + } + }); + + it('should return 404 when no recordings found', async () => { + mockClient.recordings.list.mockResolvedValue([]); + + const request = createMockRequest({ callSid: TEST_CALL_SID }); + + const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toBe('No recording found'); + expect(result.error.statusCode).toBe(404); + } + }); + + it('should return 409 when more than one recording found', async () => { + mockClient.recordings.list.mockResolvedValue([ + { sid: TEST_RECORDING_SID }, + { sid: 'RE11111111111111111111111111111111' }, + ]); + + const request = createMockRequest({ callSid: TEST_CALL_SID }); + + const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toBe('More than one recording found'); + expect(result.error.statusCode).toBe(409); + } + }); + + it('should return recordingSid, key and bucket when exactly one recording found', async () => { + mockClient.recordings.list.mockResolvedValue([{ sid: TEST_RECORDING_SID }]); + + const request = createMockRequest({ callSid: TEST_CALL_SID }); + + const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data.recordingSid).toBe(TEST_RECORDING_SID); + expect(result.data.key).toBe( + `voice-recordings/${TEST_ACCOUNT_SID}/${TEST_RECORDING_SID}`, + ); + expect(result.data.bucket).toBe(TEST_BUCKET); + } + expect(mockClient.recordings.list).toHaveBeenCalledWith({ + callSid: TEST_CALL_SID, + limit: 20, + }); + }); + + it('should return 500 on unexpected error', async () => { + mockClient.recordings.list.mockRejectedValue(new Error('Twilio API error')); + + const request = createMockRequest({ callSid: TEST_CALL_SID }); + + const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toBe('Twilio API error'); + expect(result.error.statusCode).toBe(500); + } + }); +}); diff --git a/lambdas/account-scoped/tests/unit/conversation/getMediaUrl.test.ts b/lambdas/account-scoped/tests/unit/conversation/getMediaUrl.test.ts new file mode 100644 index 0000000000..b3f6a2607c --- /dev/null +++ b/lambdas/account-scoped/tests/unit/conversation/getMediaUrl.test.ts @@ -0,0 +1,158 @@ +/** + * Copyright (C) 2021-2023 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 { getMediaUrlHandler } from '../../../src/conversation/getMediaUrl'; +import { getAccountAuthToken } from '@tech-matters/twilio-configuration'; +import { isErr, isOk } from '../../../src/Result'; +import { FlexValidatedHttpRequest } from '../../../src/validation/flexToken'; +import { TEST_ACCOUNT_SID, TEST_AUTH_TOKEN } from '../../testTwilioValues'; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getAccountAuthToken: jest.fn(), +})); + +const mockGetAccountAuthToken = getAccountAuthToken as jest.MockedFunction< + typeof getAccountAuthToken +>; + +global.fetch = jest.fn(); + +const TEST_SERVICE_SID = 'ISservice00000000000000000000000000'; +const TEST_MEDIA_SID = 'ME00000000000000000000000000000000'; +const TEST_MEDIA_URL = + 'https://mcs.us1.twilio.com/v1/Services/IS.../Media/ME.../Content?token=xyz'; + +const createMockRequest = (body: any): FlexValidatedHttpRequest => ({ + method: 'POST', + headers: {}, + path: '/test', + query: {}, + body, + tokenResult: { worker_sid: 'WK1234', roles: ['agent'] }, +}); + +describe('getMediaUrlHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAccountAuthToken.mockResolvedValue(TEST_AUTH_TOKEN); + }); + + afterEach(() => { + (global.fetch as jest.Mock).mockReset(); + }); + + it('should return 400 when serviceSid is missing', async () => { + const request = createMockRequest({ mediaSid: TEST_MEDIA_SID }); + + const result = await getMediaUrlHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toContain('serviceSid'); + expect(result.error.statusCode).toBe(400); + } + }); + + it('should return 400 when mediaSid is missing', async () => { + const request = createMockRequest({ serviceSid: TEST_SERVICE_SID }); + + const result = await getMediaUrlHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toContain('mediaSid'); + expect(result.error.statusCode).toBe(400); + } + }); + + it('should return the content_direct_temporary URL on success', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: jest.fn().mockResolvedValue({ + links: { content_direct_temporary: TEST_MEDIA_URL }, + }), + }); + + const request = createMockRequest({ + serviceSid: TEST_SERVICE_SID, + mediaSid: TEST_MEDIA_SID, + }); + + const result = await getMediaUrlHandler(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data).toBe(TEST_MEDIA_URL); + } + + expect(global.fetch).toHaveBeenCalledWith( + `https://mcs.us1.twilio.com/v1/Services/${TEST_SERVICE_SID}/Media/${TEST_MEDIA_SID}`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), + }), + ); + + // Verify the Authorization header uses account SID and auth token + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const expectedCredentials = Buffer.from( + `${TEST_ACCOUNT_SID}:${TEST_AUTH_TOKEN}`, + ).toString('base64'); + expect(fetchCall[1].headers.Authorization).toBe(`Basic ${expectedCredentials}`); + }); + + it('should return error when fetch response is not ok', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: jest.fn().mockResolvedValue({ status: 404, message: 'Not found' }), + }); + + const request = createMockRequest({ + serviceSid: TEST_SERVICE_SID, + mediaSid: TEST_MEDIA_SID, + }); + + const result = await getMediaUrlHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(404); + } + }); + + it('should return 500 on unexpected error', async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + const request = createMockRequest({ + serviceSid: TEST_SERVICE_SID, + mediaSid: TEST_MEDIA_SID, + }); + + const result = await getMediaUrlHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toBe('Network error'); + expect(result.error.statusCode).toBe(500); + } + }); +}); diff --git a/lambdas/packages/twilio-configuration/src/twilioConfiguration.ts b/lambdas/packages/twilio-configuration/src/twilioConfiguration.ts index 4680cd52da..41c30cc007 100644 --- a/lambdas/packages/twilio-configuration/src/twilioConfiguration.ts +++ b/lambdas/packages/twilio-configuration/src/twilioConfiguration.ts @@ -105,6 +105,9 @@ export const getConversationsTransferWorkflow = ( export const getServerlessBaseUrl = (accountSid: AccountSID): Promise => getSsmParameter(`/${process.env.NODE_ENV}/serverless/${accountSid}/base_url`); +export const getDocsBucketName = (accountSid: AccountSID): Promise => + getSsmParameter(`/${process.env.NODE_ENV}/s3/${accountSid}/docs_bucket_name`); + export const getTwilioClient = async (accountSid: AccountSID): Promise => { const authToken = await getAccountAuthToken(accountSid); return twilio(accountSid, authToken); diff --git a/plugin-hrm-form/src/___tests__/services/ContactService.test.ts b/plugin-hrm-form/src/___tests__/services/ContactService.test.ts index 2bcbf94658..69979e426b 100644 --- a/plugin-hrm-form/src/___tests__/services/ContactService.test.ts +++ b/plugin-hrm-form/src/___tests__/services/ContactService.test.ts @@ -43,14 +43,16 @@ jest.mock('../../services/formSubmissionHelpers', () => ({ }), })); -jest.mock('../../services/ServerlessService', () => ({ - getExternalRecordingS3Location: () => - Promise.resolve({ - status: 'success', - recordingSid: 'recordingSid', - bucket: 'bucket', - key: 'key', - }), +jest.mock('../../services/recordingsService', () => ({ + getExternalRecordingInfo: (task: any) => { + const { conference } = task?.attributes ?? {}; + if (!conference) { + return Promise.resolve({ status: 'failure', name: 'NoConference', error: 'Could not find a conference' }); + } + return Promise.resolve({ status: 'success', recordingSid: 'recordingSid', bucket: 'bucket', key: 'key' }); + }, + isFailureExternalRecordingInfo: (r: any) => r && r.status === 'failure', + shouldGetExternalRecordingInfo: () => true, })); jest.mock('@twilio/flex-ui', () => ({ diff --git a/plugin-hrm-form/src/___tests__/services/recordingsService.test.ts b/plugin-hrm-form/src/___tests__/services/recordingsService.test.ts new file mode 100644 index 0000000000..4e2f922035 --- /dev/null +++ b/plugin-hrm-form/src/___tests__/services/recordingsService.test.ts @@ -0,0 +1,162 @@ +/** + * Copyright (C) 2021-2023 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/. + */ + +/* eslint-disable camelcase */ +import { getMediaUrl, getExternalRecordingInfo } from '../../services/recordingsService'; +import fetchProtectedApi from '../../services/fetchProtectedApi'; +import { getAseloFeatureFlags, getHrmConfig } from '../../hrmConfig'; + +jest.mock('../../services/fetchProtectedApi'); +jest.mock('../../hrmConfig'); +jest.mock('@twilio/flex-ui', () => ({ + TaskHelper: { + isCallTask: jest.fn().mockReturnValue(true), + isChatBasedTask: jest.fn().mockReturnValue(false), + }, +})); +jest.mock('../../fullStory', () => ({ recordEvent: jest.fn() })); +jest.mock('../../states/DomainConstants', () => ({ + isVoiceChannel: jest.fn().mockReturnValue(true), +})); +jest.mock('../../types/types', () => ({ + isOfflineContactTask: jest.fn().mockReturnValue(false), + isTwilioTask: jest.fn().mockReturnValue(true), +})); + +const mockFetchProtectedApi = fetchProtectedApi as jest.MockedFunction; +const mockGetAseloFeatureFlags = getAseloFeatureFlags as jest.MockedFunction; +const mockGetHrmConfig = getHrmConfig as jest.MockedFunction; + +const TEST_SERVICE_SID = 'ISservice0000'; +const TEST_MEDIA_SID = 'ME000000'; +const TEST_MEDIA_URL = 'https://mcs.us1.twilio.com/v1/Services/IS.../Media/ME.../Content?token=xyz'; +const TEST_CALL_SID = 'CA00000000000'; +const TEST_RECORDING_SID = 'RE00000000000'; +const TEST_BUCKET = 'test-docs-bucket'; +const TEST_KEY = `voice-recordings/ACtest/${TEST_RECORDING_SID}`; + +const makeVoiceTask = () => + ({ + taskSid: 'WTtest', + sid: 'WRtest', + status: 'assigned', + channelType: 'voice', + attributes: { + conference: { participants: { worker: TEST_CALL_SID } }, + }, + } as any); + +beforeEach(() => { + mockFetchProtectedApi.mockClear(); + mockGetAseloFeatureFlags.mockClear(); + mockGetHrmConfig.mockReturnValue({ externalRecordingsEnabled: true, docsBucket: TEST_BUCKET } as any); +}); + +describe('getMediaUrl', () => { + describe('feature flag disabled (serverless)', () => { + beforeEach(() => { + mockGetAseloFeatureFlags.mockReturnValue({ use_twilio_lambda_for_recordings_lookup: false } as any); + mockFetchProtectedApi.mockResolvedValue(TEST_MEDIA_URL); + }); + + test('calls fetchProtectedApi with serverless endpoint', async () => { + await getMediaUrl(TEST_SERVICE_SID, TEST_MEDIA_SID); + expect(mockFetchProtectedApi).toHaveBeenCalledWith( + '/getMediaUrl', + { serviceSid: TEST_SERVICE_SID, mediaSid: TEST_MEDIA_SID }, + { useTwilioLambda: false }, + ); + }); + + test('returns the response', async () => { + const result = await getMediaUrl(TEST_SERVICE_SID, TEST_MEDIA_SID); + expect(result).toBe(TEST_MEDIA_URL); + }); + }); + + describe('feature flag enabled (lambda)', () => { + beforeEach(() => { + mockGetAseloFeatureFlags.mockReturnValue({ use_twilio_lambda_for_recordings_lookup: true } as any); + mockFetchProtectedApi.mockResolvedValue(TEST_MEDIA_URL); + }); + + test('calls fetchProtectedApi with lambda endpoint', async () => { + await getMediaUrl(TEST_SERVICE_SID, TEST_MEDIA_SID); + expect(mockFetchProtectedApi).toHaveBeenCalledWith( + '/conversation/getMediaUrl', + { serviceSid: TEST_SERVICE_SID, mediaSid: TEST_MEDIA_SID }, + { useTwilioLambda: true }, + ); + }); + + test('returns the response', async () => { + const result = await getMediaUrl(TEST_SERVICE_SID, TEST_MEDIA_SID); + expect(result).toBe(TEST_MEDIA_URL); + }); + }); +}); + +describe('getExternalRecordingInfo (getExternalRecordingS3Location routing)', () => { + const recordingResponse = { recordingSid: TEST_RECORDING_SID, bucket: TEST_BUCKET, key: TEST_KEY }; + const expectedRecordingInfo = { + status: 'success', + recordingSid: TEST_RECORDING_SID, + bucket: TEST_BUCKET, + key: TEST_KEY, + }; + + describe('feature flag disabled (serverless)', () => { + beforeEach(() => { + mockGetAseloFeatureFlags.mockReturnValue({ use_twilio_lambda_for_recordings_lookup: false } as any); + mockFetchProtectedApi.mockResolvedValue(recordingResponse); + }); + + test('calls fetchProtectedApi with serverless endpoint', async () => { + await getExternalRecordingInfo(makeVoiceTask()); + expect(mockFetchProtectedApi).toHaveBeenCalledWith( + '/getExternalRecordingS3Location', + { callSid: TEST_CALL_SID }, + { useTwilioLambda: false }, + ); + }); + + test('returns the recording info', async () => { + const result = await getExternalRecordingInfo(makeVoiceTask()); + expect(result).toMatchObject(expectedRecordingInfo); + }); + }); + + describe('feature flag enabled (lambda)', () => { + beforeEach(() => { + mockGetAseloFeatureFlags.mockReturnValue({ use_twilio_lambda_for_recordings_lookup: true } as any); + mockFetchProtectedApi.mockResolvedValue(recordingResponse); + }); + + test('calls fetchProtectedApi with lambda endpoint', async () => { + await getExternalRecordingInfo(makeVoiceTask()); + expect(mockFetchProtectedApi).toHaveBeenCalledWith( + '/conversation/getExternalRecordingS3Location', + { callSid: TEST_CALL_SID }, + { useTwilioLambda: true }, + ); + }); + + test('returns the recording info', async () => { + const result = await getExternalRecordingInfo(makeVoiceTask()); + expect(result).toMatchObject(expectedRecordingInfo); + }); + }); +}); diff --git a/plugin-hrm-form/src/components/Messaging/MessageItem/MessageItem.tsx b/plugin-hrm-form/src/components/Messaging/MessageItem/MessageItem.tsx index 990f7cc7a9..8311ba37ab 100644 --- a/plugin-hrm-form/src/components/Messaging/MessageItem/MessageItem.tsx +++ b/plugin-hrm-form/src/components/Messaging/MessageItem/MessageItem.tsx @@ -38,7 +38,7 @@ import { import { TranscriptMessage } from '../../../states/contacts/existingContacts'; import { abbreviateMediaFilename, displayMediaSize, selectMediaIcon } from '../../../utils/selectMediaIcon'; import OpenPageIcon from '../../common/icons/OpenPageIcon'; -import { getMediaUrl } from '../../../services/ServerlessService'; +import { getMediaUrl } from '../../../services/recordingsService'; export type GroupedMessage = TranscriptMessage & { friendlyName: string; diff --git a/plugin-hrm-form/src/services/ContactService.ts b/plugin-hrm-form/src/services/ContactService.ts index 7971357ead..a97a66916b 100644 --- a/plugin-hrm-form/src/services/ContactService.ts +++ b/plugin-hrm-form/src/services/ContactService.ts @@ -29,7 +29,7 @@ import { getExternalRecordingInfo, isFailureExternalRecordingInfo, shouldGetExternalRecordingInfo, -} from './getExternalRecordingInfo'; +} from './recordingsService'; import { ApiSearchParams } from '../states/search/types'; import { ContactDraftChanges } from '../states/contacts/existingContacts'; import { newContact } from '../states/contacts/contactState'; diff --git a/plugin-hrm-form/src/services/InsightsService.ts b/plugin-hrm-form/src/services/InsightsService.ts index dedb743371..d7bc80ec6f 100644 --- a/plugin-hrm-form/src/services/InsightsService.ts +++ b/plugin-hrm-form/src/services/InsightsService.ts @@ -37,7 +37,7 @@ import { ExternalRecordingInfo, ExternalRecordingInfoSuccess, isSuccessfulExternalRecordingInfo, -} from './getExternalRecordingInfo'; +} from './recordingsService'; import { generateUrl } from './fetchApi'; import { generateSignedURLPath } from './fetchHrmApi'; import { shouldSendInsightsData } from '../utils/shouldSendInsightsData'; diff --git a/plugin-hrm-form/src/services/ServerlessService.ts b/plugin-hrm-form/src/services/ServerlessService.ts index 09ae36fab6..923bb4b43e 100644 --- a/plugin-hrm-form/src/services/ServerlessService.ts +++ b/plugin-hrm-form/src/services/ServerlessService.ts @@ -45,24 +45,8 @@ export const getDefinitionVersionsList = async (missingDefinitionVersions: strin }), ); -/** - * Gets a recording s3 information from the corresponding call sid - */ -export const getExternalRecordingS3Location = async (callSid: string) => { - const body = { callSid }; - const response = await fetchProtectedApi('/getExternalRecordingS3Location', body); - return response; -}; - export const saveContactToSaferNet = async (payload: any): Promise => { const body = { payload: JSON.stringify(payload) }; const postSurveyUrl = await fetchProtectedApi('/saveContactToSaferNet', body); return postSurveyUrl; }; - -export const getMediaUrl = async (serviceSid: string, mediaSid: string) => { - const body = { serviceSid, mediaSid }; - - const response = await fetchProtectedApi('/getMediaUrl', body); - return response; -}; diff --git a/plugin-hrm-form/src/services/formSubmissionHelpers.ts b/plugin-hrm-form/src/services/formSubmissionHelpers.ts index 3d601445d3..d9b16dc35f 100644 --- a/plugin-hrm-form/src/services/formSubmissionHelpers.ts +++ b/plugin-hrm-form/src/services/formSubmissionHelpers.ts @@ -28,7 +28,7 @@ import asyncDispatch from '../states/asyncDispatch'; import { newClearContactAsyncAction, removeFromCaseAsyncAction } from '../states/contacts/saveContact'; import { getOfflineContactTaskSid } from '../states/contacts/offlineContactTask'; import { CaseStateEntry } from '../states/case/types'; -import { getExternalRecordingInfo } from './getExternalRecordingInfo'; +import { getExternalRecordingInfo } from './recordingsService'; import { assignOfflineContactInit, assignOfflineContactResolve } from './twilioTaskService'; const FINISHED_TASK_STATES: TaskReservationStatus[] = ['completed', 'canceled']; diff --git a/plugin-hrm-form/src/services/getExternalRecordingInfo.ts b/plugin-hrm-form/src/services/recordingsService.ts similarity index 85% rename from plugin-hrm-form/src/services/getExternalRecordingInfo.ts rename to plugin-hrm-form/src/services/recordingsService.ts index fa66e6f1c8..14b5006548 100644 --- a/plugin-hrm-form/src/services/getExternalRecordingInfo.ts +++ b/plugin-hrm-form/src/services/recordingsService.ts @@ -16,10 +16,10 @@ import { TaskHelper } from '@twilio/flex-ui'; -import { getHrmConfig } from '../hrmConfig'; +import fetchProtectedApi from './fetchProtectedApi'; +import { getAseloFeatureFlags, getHrmConfig } from '../hrmConfig'; import { isVoiceChannel } from '../states/DomainConstants'; import { CustomITask, InMyBehalfITask, isOfflineContactTask, isTwilioTask } from '../types/types'; -import { getExternalRecordingS3Location } from './ServerlessService'; import { recordEvent } from '../fullStory'; export type ExternalRecordingInfoSuccess = { @@ -56,6 +56,7 @@ export const shouldGetExternalRecordingInfo = (task: CustomITask): task is InMyB return Boolean(externalRecordingsEnabled); }; /* eslint-enable sonarjs/prefer-single-boolean-return */ + const recordDebugEvent = (task: InMyBehalfITask, message: string, recordingInfo: ExternalRecordingInfo | {} = {}) => { recordEvent(`[Temporary Debug Event] Getting External Recording Info: ${message}`, { ...recordingInfo, @@ -70,6 +71,22 @@ const recordDebugEvent = (task: InMyBehalfITask, message: string, recordingInfo: }); }; +const getExternalRecordingS3Location = async (callSid: string) => { + const useTwilioLambda = getAseloFeatureFlags().use_twilio_lambda_for_recordings_lookup; + const body = { callSid }; + return fetchProtectedApi( + useTwilioLambda ? '/conversation/getExternalRecordingS3Location' : '/getExternalRecordingS3Location', + body, + { useTwilioLambda }, + ); +}; + +export const getMediaUrl = async (serviceSid: string, mediaSid: string) => { + const useTwilioLambda = getAseloFeatureFlags().use_twilio_lambda_for_recordings_lookup; + const body = { serviceSid, mediaSid }; + return fetchProtectedApi(useTwilioLambda ? '/conversation/getMediaUrl' : '/getMediaUrl', body, { useTwilioLambda }); +}; + export const getExternalRecordingInfo = async (task: CustomITask): Promise => { if (!shouldGetExternalRecordingInfo(task)) { return { diff --git a/plugin-hrm-form/src/types/FeatureFlags.ts b/plugin-hrm-form/src/types/FeatureFlags.ts index 0a3173d8fc..a0c1a240c6 100644 --- a/plugin-hrm-form/src/types/FeatureFlags.ts +++ b/plugin-hrm-form/src/types/FeatureFlags.ts @@ -49,6 +49,7 @@ export type FeatureFlags = { use_twilio_lambda_for_conversation_duration: boolean; // Use the twilio account scoped lambda to calculate conversationDuration 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 use_twilio_lambda_for_worker_endpoints: boolean; // Use the twilio account scoped lambda for getWorkerAttributes, populateCounselors and listWorkerQueues use_twilio_lambda_to_transition_participants: boolean; // Use the twilio account scoped lambda for wrapupConversationTask and completeConversationTask use_twilio_lambda_transfers: boolean; // Use the twilio account scoped lambda for transferChatStart