diff --git a/aselo-webchat-react-app/src/components/PreEngagementFormPhase.tsx b/aselo-webchat-react-app/src/components/PreEngagementFormPhase.tsx index 1082f7ca7d..38ddc6ef40 100644 --- a/aselo-webchat-react-app/src/components/PreEngagementFormPhase.tsx +++ b/aselo-webchat-react-app/src/components/PreEngagementFormPhase.tsx @@ -20,12 +20,9 @@ import { Button } from '@twilio-paste/core/button'; import { useDispatch, useSelector } from 'react-redux'; import { Text } from '@twilio-paste/core/text'; -import { sessionDataHandler } from '../sessionDataHandler'; -import { addNotification, changeEngagementPhase, updatePreEngagementDataField } from '../store/actions/genericActions'; -import { initSession } from '../store/actions/initActions'; -import { AppState, EngagementPhase } from '../store/definitions'; +import { submitAndInitChatThunk, updatePreEngagementDataField } from '../store/actions/genericActions'; +import { AppState } from '../store/definitions'; import { Header } from './Header'; -import { notifications } from '../notifications'; import { NotificationBar } from './NotificationBar'; import { fieldStyles, titleStyles, formStyles } from './styles/PreEngagementFormPhase.styles'; import LocalizedTemplate from '../localization/LocalizedTemplate'; @@ -36,8 +33,6 @@ export const PreEngagementFormPhase = () => { const { preEngagementFormDefinition } = useSelector((state: AppState) => state.config); const dispatch = useDispatch(); - const { friendlyName } = preEngagementData; - const getItem = (inputName: string) => preEngagementData[inputName] ?? {}; const setItemValue = (payload: { name: string; value: string | boolean }) => { dispatch(updatePreEngagementDataField(payload)); @@ -46,24 +41,7 @@ export const PreEngagementFormPhase = () => { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - dispatch(changeEngagementPhase({ phase: EngagementPhase.Loading })); - try { - const data = await sessionDataHandler.fetchAndStoreNewSession({ - formData: { - ...preEngagementData, - friendlyName, - }, - }); - dispatch( - initSession({ - token: data.token, - conversationSid: data.conversationSid, - }), - ); - } catch (err) { - dispatch(addNotification(notifications.failedToInitSessionNotification((err as Error).message))); - dispatch(changeEngagementPhase({ phase: EngagementPhase.PreEngagementForm })); - } + await dispatch(submitAndInitChatThunk() as any); }; if (!preEngagementFormDefinition) { diff --git a/aselo-webchat-react-app/src/components/__tests__/PreEngagementFormPhase.test.tsx b/aselo-webchat-react-app/src/components/__tests__/PreEngagementFormPhase.test.tsx index d8895432ad..6b12dde41d 100644 --- a/aselo-webchat-react-app/src/components/__tests__/PreEngagementFormPhase.test.tsx +++ b/aselo-webchat-react-app/src/components/__tests__/PreEngagementFormPhase.test.tsx @@ -184,9 +184,9 @@ describe('Pre Engagement Form Phase', () => { expect(fetchAndStoreNewSessionSpy).toHaveBeenCalledWith({ formData: { - friendlyName: { value: name, error: null, dirty: true }, - query: { value: query, error: null, dirty: true }, - email: { value: email, error: null, dirty: true }, + friendlyName: name, + query, + email, }, }); }); diff --git a/aselo-webchat-react-app/src/components/forms/formInputs/validation.ts b/aselo-webchat-react-app/src/components/forms/formInputs/validation.ts index 94b9f28747..c7c1a2003a 100644 --- a/aselo-webchat-react-app/src/components/forms/formInputs/validation.ts +++ b/aselo-webchat-react-app/src/components/forms/formInputs/validation.ts @@ -50,7 +50,11 @@ const validateEmailPattern = ({ return null; } - const matches = (value as string).match(EMAIL_PATTERN); + if (!value) { + return null; + } + + const matches = (value as string)?.match(EMAIL_PATTERN); if (Boolean(matches?.length)) { return null; diff --git a/aselo-webchat-react-app/src/notifications.ts b/aselo-webchat-react-app/src/notifications.ts index 0db063e3d7..035bf78a57 100644 --- a/aselo-webchat-react-app/src/notifications.ts +++ b/aselo-webchat-react-app/src/notifications.ts @@ -98,6 +98,14 @@ const failedToInitSessionNotification = (error: string): Notification => ({ type: 'error', }); +const formValidationErrorNotification = (): Notification => ({ + id: `FailedToInitSessionNotification`, + dismissible: true, + message: 'Some fields contain errors', + type: 'error', + timeout: 5000, +}); + export const notifications = { fileAttachmentAlreadyAttachedNotification, fileAttachmentInvalidSizeNotification, @@ -106,4 +114,5 @@ export const notifications = { fileDownloadInvalidTypeNotification, noConnectionNotification, failedToInitSessionNotification, + formValidationErrorNotification, }; diff --git a/aselo-webchat-react-app/src/store/actions/genericActions.ts b/aselo-webchat-react-app/src/store/actions/genericActions.ts index 9df48c990c..b7148c806b 100644 --- a/aselo-webchat-react-app/src/store/actions/genericActions.ts +++ b/aselo-webchat-react-app/src/store/actions/genericActions.ts @@ -32,6 +32,10 @@ import { } from './actionTypes'; import { MESSAGES_LOAD_COUNT } from '../../constants'; import { validateInput } from '../../components/forms/formInputs/validation'; +import { sessionDataHandler } from '../../sessionDataHandler'; +import { getDefaultValue } from '../../components/forms/formInputs'; +import { initSession } from './initActions'; +import { notifications } from '../../notifications'; export function changeEngagementPhase({ phase }: { phase: EngagementPhase }) { return { @@ -136,3 +140,49 @@ export const updatePreEngagementDataField = ({ }); }; }; + +const getInitialItem = (definition: PreEngagementFormItem): PreEngagementDataItem => ({ + error: null, + dirty: false, + value: getDefaultValue(definition), +}); + +export const submitAndInitChatThunk = (): ThunkAction => { + return async (dispatch, getState) => { + const state = getState(); + const definition = state.config.preEngagementFormDefinition?.fields || []; + const data = definition.reduce((accum, def) => { + const item = state.session.preEngagementData[def.name]; + const error = validateInput({ definition: def, value: item?.value }); + return { ...accum, [def.name]: { ...(item || getInitialItem(def)), error } }; + }, {}); + + dispatch(updatePreEngagementData(data)); + + const hasError = Object.values(data).some(i => Boolean(i.error)); + if (hasError) { + dispatch(addNotification(notifications.formValidationErrorNotification())); + return; + } + + dispatch(changeEngagementPhase({ phase: EngagementPhase.Loading })); + try { + const preEngagementDataValues = Object.entries(data).reduce( + (accum, [name, { value }]) => ({ ...accum, [name]: value }), + {}, + ); + const sessionData = await sessionDataHandler.fetchAndStoreNewSession({ + formData: preEngagementDataValues, + }); + dispatch( + initSession({ + token: sessionData.token, + conversationSid: sessionData.conversationSid, + }), + ); + } catch (err) { + dispatch(addNotification(notifications.failedToInitSessionNotification((err as Error).message))); + dispatch(changeEngagementPhase({ phase: EngagementPhase.PreEngagementForm })); + } + }; +}; diff --git a/aselo-webchat-react-app/src/store/actions/initActions.ts b/aselo-webchat-react-app/src/store/actions/initActions.ts index 9c4f17d375..ae6ddd2fdb 100644 --- a/aselo-webchat-react-app/src/store/actions/initActions.ts +++ b/aselo-webchat-react-app/src/store/actions/initActions.ts @@ -29,7 +29,6 @@ import { ACTION_LOAD_CONFIG_REQUEST, ACTION_LOAD_CONFIG_SUCCESS, ACTION_LOAD_CONFIG_FAILURE, - ACTION_UPDATE_PRE_ENGAGEMENT_DATA, } from './actionTypes'; import { addNotification, changeEngagementPhase } from './genericActions'; import { MESSAGES_LOAD_COUNT } from '../../constants'; @@ -38,7 +37,6 @@ import { sessionDataHandler } from '../../sessionDataHandler'; import { createParticipantNameMap } from '../../utils/participantNameMap'; import { getDefinitionVersion } from '../../services/configService'; import type { AppState } from '../store'; -import { getDefaultValue } from '../../components/forms/formInputs'; export const initConfigThunk = ( config: Omit, diff --git a/lambdas/account-scoped/src/webchatAuthentication/initWebchat.ts b/lambdas/account-scoped/src/webchatAuthentication/initWebchat.ts index e72a033cab..a347595535 100644 --- a/lambdas/account-scoped/src/webchatAuthentication/initWebchat.ts +++ b/lambdas/account-scoped/src/webchatAuthentication/initWebchat.ts @@ -14,50 +14,48 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { getAccountAuthToken, getTwilioClient } from '@tech-matters/twilio-configuration'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; import type { AccountScopedHandler, HttpError } from '../httpTypes'; import type { AccountSID, ConversationSID } from '@tech-matters/twilio-types'; import { isErr, newErr, newOk, Result } from '../Result'; import { createToken, TOKEN_TTL_IN_SECONDS } from './createToken'; -const contactWebchatOrchestrator = async ( - accountSid: AccountSID, - addressSid: string, - formData: any, - customerFriendlyName: string, -): Promise> => { +const contactWebchatOrchestrator = async ({ + addressSid, + accountSid, + formData, + customerFriendlyName, +}: { + accountSid: AccountSID; + addressSid: string; + formData: Record; + customerFriendlyName: string; +}): Promise< + Result +> => { console.info('Calling Webchat Orchestrator'); - const params = new URLSearchParams(); - params.append('AddressSid', addressSid); - params.append('ChatFriendlyName', 'Webchat widget'); - params.append('CustomerFriendlyName', customerFriendlyName); - params.append( - 'PreEngagementData', - JSON.stringify({ - ...formData, - friendlyName: customerFriendlyName, - }), - ); - const authToken = await getAccountAuthToken(accountSid); + try { + const client = await getTwilioClient(accountSid); + const orchestratorResponse = await client.flexApi.v2.webChannels.create({ + customerFriendlyName, + addressSid, + preEngagementData: JSON.stringify(formData), + uiVersion: process.env.WEBCHAT_VERSION || '1.0.0', + chatFriendlyName: 'Webchat widget', + }); + console.info('Webchat Orchestrator successfully called', orchestratorResponse); - const res = await fetch(`https://flex-api.twilio.com/v2/WebChats`, { - method: 'POST', - headers: { - Authorization: - 'Basic ' + Buffer.from(accountSid + ':' + authToken).toString('base64'), - 'ui-version': process.env.WEBCHAT_VERSION || '1.0.0', - }, - body: params, - }); - if (!res.ok) { - const bodyError = await res.text(); - console.error( - 'Error calling https://flex-api.twilio.com/v2/WebChats', - accountSid, - bodyError, - ); + const { conversationSid, identity } = orchestratorResponse; + + return newOk({ + conversationSid: conversationSid as ConversationSID, + identity, + }); + } catch (err) { + const bodyError = err instanceof Error ? err.message : String(err); + console.error('Error creating web channel', accountSid, bodyError); return newErr({ message: bodyError, error: { @@ -66,16 +64,6 @@ const contactWebchatOrchestrator = async ( }, }); } - const orchestratorResponse = (await res.json()) as any; - - console.info('Webchat Orchestrator successfully called', orchestratorResponse); - - const { conversation_sid: conversationSid, identity } = orchestratorResponse; - - return newOk({ - conversationSid, - identity, - }); }; const sendUserMessage = async ( @@ -116,18 +104,20 @@ const sendWelcomeMessage = async ( export const initWebchatHandler: AccountScopedHandler = async (request, accountSid) => { console.info('Initiating webchat', accountSid); - const customerFriendlyName = request.body?.formData?.friendlyName || 'Customer'; + const formData = JSON.parse(request.body?.PreEngagementData); + const customerFriendlyName = + formData?.friendlyName || request.body?.CustomerFriendlyName || 'Customer'; let conversationSid: ConversationSID; let identity; // Hit Webchat Orchestration endpoint to generate conversation and get customer participant sid - const result = await contactWebchatOrchestrator( + const result = await contactWebchatOrchestrator({ accountSid, - 'IG1ba46f2d6828b42ddd363f5045138044', // Obvs needs to be SSM parameter - request.body?.formData, + addressSid: 'IG1ba46f2d6828b42ddd363f5045138044', // Obvs needs to be SSM parameter + formData, customerFriendlyName, - ); + }); if (isErr(result)) { return result; }