diff --git a/functions/hrm/populateHrmContactFormFromTask.ts b/functions/hrm/populateHrmContactFormFromTask.ts deleted file mode 100644 index 24def61e..00000000 --- a/functions/hrm/populateHrmContactFormFromTask.ts +++ /dev/null @@ -1,479 +0,0 @@ -/** - * 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 { capitalize } from 'lodash'; -import { startOfDay, format } from 'date-fns'; - -type MapperFunction = (options: string[]) => (value: string) => string; - -// When we move this into the flex repo we can depend on hrm-form-definitions for these types & enums -enum FormInputType { - Input = 'input', - SearchInput = 'search-input', - NumericInput = 'numeric-input', - Email = 'email', - RadioInput = 'radio-input', - ListboxMultiselect = 'listbox-multiselect', - Select = 'select', - DependentSelect = 'dependent-select', - Checkbox = 'checkbox', - MixedCheckbox = 'mixed-checkbox', - Textarea = 'textarea', - DateInput = 'date-input', - TimeInput = 'time-input', - FileUpload = 'file-upload', - Button = 'button', - CopyTo = 'copy-to', - CustomContactComponent = 'custom-contact-component', -} - -type FormItemDefinition = { - name: string; - unknownOption?: string; - options?: { value: string }[]; - initialChecked?: boolean; - initializeWithCurrent?: boolean; -} & ( - | { - type: Exclude; - defaultOption?: string; - } - | { - type: FormInputType.DependentSelect; - defaultOption: { - value: string; - }; - } -); - -type PrepopulateKeys = { - preEngagement: { - ChildInformationTab: string[]; - CallerInformationTab: string[]; - CaseInformationTab: string[]; - }; - survey: { ChildInformationTab: string[]; CallerInformationTab: string[] }; -}; - -type ChannelTypes = - | 'voice' - | 'sms' - | 'facebook' - | 'messenger' - | 'whatsapp' - | 'web' - | 'telegram' - | 'instagram' - | 'line' - | 'modica'; - -const callTypes = { - child: 'Child calling about self', - caller: 'Someone calling about a child', -}; - -type HrmContactRawJson = { - definitionVersion?: string; - callType: typeof callTypes[keyof typeof callTypes]; - childInformation: Record; - callerInformation: Record; - caseInformation: Record; - categories: Record; - contactlessTask: { - channel: ChannelTypes; - date: string; - time: string; - createdOnBehalfOf: `WK${string}` | ''; - [key: string]: string | boolean; - }; -}; - -export type HrmContact = { - id: string; - accountSid?: `AC${string}`; - twilioWorkerId?: `WK${string}`; - number: string; - conversationDuration: number; - csamReports: unknown[]; - referrals?: unknown[]; - conversationMedia?: unknown[]; - createdAt: string; - createdBy: string; - helpline: string; - taskId: `WT${string}` | null; - profileId?: string; - identifierId?: string; - channel: ChannelTypes | 'default'; - updatedBy: string; - updatedAt?: string; - finalizedAt?: string; - rawJson: HrmContactRawJson; - timeOfContact: string; - queueName: string; - channelSid: string; - serviceSid: string; - caseId?: string; -}; - -type FormValue = string | string[] | boolean | null; - -// This hardcoded logic should be moved into the form definition JSON or some other configuration -const MANDATORY_CHATBOT_FIELDS = ['age', 'gender', 'ethnicity']; - -const CUSTOM_MAPPERS: Record = { - age: - (ageOptions: string[]) => - (age: string): string => { - const ageInt = parseInt(age, 10); - - const maxAge = ageOptions.find((e) => e.includes('>')); - - if (maxAge) { - const maxAgeInt = parseInt(maxAge.replace('>', ''), 10); - - if (ageInt >= 0 && ageInt <= maxAgeInt) { - return ageOptions.find((o) => parseInt(o, 10) === ageInt) || 'Unknown'; - } - - if (ageInt > maxAgeInt) return maxAge; - } else { - console.error('Pre populate form error: no maxAge option provided.'); - } - - return 'Unknown'; - }, -}; - -/** - * Utility functions to create initial state from definition - * @param {FormItemDefinition} def Definition for a single input of a Form - */ -const getInitialValue = (def: FormItemDefinition): FormValue => { - switch (def.type) { - case FormInputType.Input: - case FormInputType.NumericInput: - case FormInputType.Email: - case FormInputType.Textarea: - case FormInputType.FileUpload: - return ''; - case FormInputType.DateInput: { - if (def.initializeWithCurrent) { - return format(startOfDay(new Date()), 'yyyy-MM-dd'); - } - return ''; - } - case FormInputType.TimeInput: { - if (def.initializeWithCurrent) { - return format(new Date(), 'HH:mm'); - } - - return ''; - } - case FormInputType.RadioInput: - return def.defaultOption ?? ''; - case FormInputType.ListboxMultiselect: - return []; - case FormInputType.Select: - if (def.defaultOption) return def.defaultOption; - return def.options && def.options[0] ? def.options[0].value : null; - case FormInputType.DependentSelect: - return def.defaultOption?.value; - case FormInputType.CopyTo: - case FormInputType.Checkbox: - return Boolean(def.initialChecked); - case 'mixed-checkbox': - return def.initialChecked === undefined ? 'mixed' : def.initialChecked; - default: - return null; - } -}; - -const mapGenericOption = (options: string[]) => (value: string) => { - const validOption = options.find((e) => e.toLowerCase() === value.toLowerCase()); - - if (!validOption) { - return 'Unknown'; - } - - return validOption; -}; - -const getUnknownOption = (key: string, definition: FormItemDefinition[]) => { - const inputDef = definition.find((e) => e.name === key); - - // inputDef.options check needed whilst we use an el cheapo copy of the type, once we share the flex type it won't be needed - if (inputDef?.type === 'select' && inputDef.options) { - const unknownOption = inputDef.unknownOption - ? inputDef.options.find((e) => e.value === inputDef.unknownOption) - : inputDef.options.find((e) => e.value === 'Unknown'); - if (unknownOption && unknownOption.value) return unknownOption.value; - - console.error(`getUnknownOption couldn't determine a valid unknown option for key ${key}.`); - } - - return 'Unknown'; -}; - -/** - * Given a key and a form definition, grabs the input with name that equals the key and return the options values, or empty array. - */ -const getSelectOptions = (key: string) => (definition: FormItemDefinition[]) => { - const inputDef = definition.find((e) => e.name === key); - // inputDef.options check needed whilst we use an el cheapo copy of the type, once we share the flex type it won't be needed - if (inputDef?.type === 'select' && inputDef.options) { - return inputDef.options.map((e) => e.value) || []; - } - - console.error(`getSelectOptions called with key ${key} but is a non-select input type.`); - return []; -}; - -const getAnswerOrUnknown = ( - answers: any, - key: string, - definition: FormItemDefinition[], - mapperFunction: MapperFunction = mapGenericOption, -) => { - // This keys must be set with 'Unknown' value even if there's no answer - const isRequiredKey = key === 'age' || key === 'gender'; - - // This prevents setting redux state with the 'Unknown' value for a property that is not asked by the pre-survey - if (!isRequiredKey && !answers[key]) return null; - - const itemDefinition = definition.find((e) => e.name === key); - - // This prevents setting redux state with the 'Unknown' value for a property that is not present on the definition - if (!itemDefinition) { - console.error(`${key} does not exist in the current definition`); - return null; - } - - if (itemDefinition.type === 'select') { - const unknown = getUnknownOption(key, definition); - const isUnknownAnswer = !answers[key] || answers[key] === unknown; - - if (isUnknownAnswer) return unknown; - - const options = getSelectOptions(key)(definition); - const result = mapperFunction(options)(answers[key]); - - return result === 'Unknown' ? unknown : result; - } - - return answers[key]; -}; - -const getValuesFromAnswers = ( - prepopulateKeys: Set, - tabFormDefinition: FormItemDefinition[], - answers: any, -): Record => { - // Get values from task attributes - const { firstName, language } = answers; - - // Get the customizable values from the bot's memory if there's any value (defined in PrepopulateKeys.json) - const customizableValues = Array.from(prepopulateKeys).reduce((accum, key) => { - const value = getAnswerOrUnknown( - answers, - key, - tabFormDefinition, - CUSTOM_MAPPERS[key] || mapGenericOption, - ); - return value ? { ...accum, [key]: value } : accum; - }, {}); - - return { - ...(firstName && { firstName }), - ...(language && { language: capitalize(language) }), - ...customizableValues, - }; -}; - -const getValuesFromPreEngagementData = ( - prepopulateKeySet: Set, - tabFormDefinition: FormItemDefinition[], - preEngagementData: Record, -) => { - // Get values from task attributes - const values: Record = {}; - const prepopulateKeys = Array.from(prepopulateKeySet); - tabFormDefinition.forEach((field: FormItemDefinition) => { - if (prepopulateKeys.indexOf(field.name) > -1) { - if (['mixed-checkbox', 'checkbox'].includes(field.type)) { - const fieldValue = preEngagementData[field.name]?.toLowerCase(); - if (fieldValue === 'yes') { - values[field.name] = true; - } else if (fieldValue === 'no' || field.type === 'checkbox') { - values[field.name] = false; - } - return; - } - values[field.name] = preEngagementData[field.name] || ''; - } - }); - return values; -}; - -const loadedConfigJsons: Record = {}; - -const loadConfigJson = async (formDefinitionRootUrl: URL, section: string): Promise => { - if (!loadedConfigJsons[section]) { - const url = `${formDefinitionRootUrl}/${section}.json`; - const response = await fetch(url); - loadedConfigJsons[section] = response.json(); - } - return loadedConfigJsons[section]; -}; - -const populateInitialValues = async (contact: HrmContact, formDefinitionRootUrl: URL) => { - const tabNamesAndRawJsonSections: [string, Record][] = [ - ['CaseInformationTab', contact.rawJson.caseInformation], - ['ChildInformationTab', contact.rawJson.childInformation], - ['CallerInformationTab', contact.rawJson.callerInformation], - ]; - - const defintionsAndJsons: [FormItemDefinition[], Record][] = await Promise.all( - tabNamesAndRawJsonSections.map(async ([tabbedFormsSection, rawJsonSection]) => [ - await loadConfigJson(formDefinitionRootUrl, `tabbedForms/${tabbedFormsSection}`), - rawJsonSection, - ]), - ); - for (const [tabFormDefinition, rawJson] of defintionsAndJsons) { - for (const formItemDefinition of tabFormDefinition) { - rawJson[formItemDefinition.name] = getInitialValue(formItemDefinition); - } - } - const helplineInformation = await loadConfigJson(formDefinitionRootUrl, 'HelplineInformation'); - const defaultHelplineOption = ( - helplineInformation.helplines.find((helpline: any) => helpline.default) || - helplineInformation.helplines[0] - ).value; - Object.assign(contact.rawJson.contactlessTask, { - date: getInitialValue({ - type: FormInputType.DateInput, - initializeWithCurrent: true, - name: 'date', - }), - time: getInitialValue({ - type: FormInputType.TimeInput, - initializeWithCurrent: true, - name: 'time', - }), - helpline: defaultHelplineOption, - }); -}; - -const populateContactSection = async ( - target: Record, - valuesToPopulate: Record, - keys: Set, - formDefinitionRootUrl: URL, - tabbedFormsSection: 'CaseInformationTab' | 'ChildInformationTab' | 'CallerInformationTab', - converter: ( - keys: Set, - formTabDefinition: FormItemDefinition[], - values: Record, - ) => Record, -) => { - console.debug('Populating', tabbedFormsSection); - console.debug('Keys', Array.from(keys)); - console.debug('Using Values', valuesToPopulate); - - if (keys.size > 0) { - const childInformationTabDefinition = await loadConfigJson( - formDefinitionRootUrl, - `tabbedForms/${tabbedFormsSection}`, - ); - Object.assign(target, converter(keys, childInformationTabDefinition, valuesToPopulate)); - } -}; - -export const populateHrmContactFormFromTask = async ( - taskAttributes: Record, - contact: HrmContact, - formDefinitionRootUrl: URL, -): Promise => { - const { memory, preEngagementData, firstName, language } = taskAttributes; - const answers = { ...memory, firstName, language }; - await populateInitialValues(contact, formDefinitionRootUrl); - if (!answers && !preEngagementData) return contact; - const { preEngagement: preEngagementKeys, survey: surveyKeys }: PrepopulateKeys = - await loadConfigJson(formDefinitionRootUrl, 'PrepopulateKeys'); - - const isValidSurvey = Boolean(answers?.aboutSelf); // determines if the memory has valid values or if it was aborted - const isAboutSelf = answers.aboutSelf === 'Yes'; - if (isValidSurvey) { - // eslint-disable-next-line no-param-reassign - contact.rawJson.callType = isAboutSelf ? callTypes.child : callTypes.caller; - } - if (preEngagementData) { - await populateContactSection( - contact.rawJson.caseInformation, - preEngagementData, - new Set(preEngagementKeys.CaseInformationTab), - formDefinitionRootUrl, - 'CaseInformationTab', - getValuesFromPreEngagementData, - ); - - if (!isValidSurvey || isAboutSelf) { - await populateContactSection( - contact.rawJson.childInformation, - preEngagementData, - new Set(preEngagementKeys.ChildInformationTab), - formDefinitionRootUrl, - 'ChildInformationTab', - getValuesFromPreEngagementData, - ); - } else { - await populateContactSection( - contact.rawJson.callerInformation, - preEngagementData, - new Set(preEngagementKeys.CallerInformationTab), - formDefinitionRootUrl, - 'CallerInformationTab', - getValuesFromPreEngagementData, - ); - } - } - - if (isValidSurvey) { - if (isAboutSelf) { - await populateContactSection( - contact.rawJson.childInformation, - answers, - new Set([...MANDATORY_CHATBOT_FIELDS, ...surveyKeys.ChildInformationTab]), - formDefinitionRootUrl, - 'ChildInformationTab', - getValuesFromAnswers, - ); - } else { - await populateContactSection( - contact.rawJson.callerInformation, - answers, - new Set([...MANDATORY_CHATBOT_FIELDS, ...surveyKeys.CallerInformationTab]), - formDefinitionRootUrl, - 'CallerInformationTab', - getValuesFromAnswers, - ); - } - } - return contact; -}; - -export type PrepopulateForm = { - populateHrmContactFormFromTask: typeof populateHrmContactFormFromTask; -}; diff --git a/functions/taskrouterListeners/createHrmContactListener.private.ts b/functions/taskrouterListeners/createHrmContactListener.private.ts deleted file mode 100644 index cdd2ef3b..00000000 --- a/functions/taskrouterListeners/createHrmContactListener.private.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * 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 global-require */ -/* eslint-disable import/no-dynamic-require */ -import '@twilio-labs/serverless-runtime-types'; -import { Context } from '@twilio-labs/serverless-runtime-types/types'; - -import { - EventFields, - EventType, - // RESERVATION_ACCEPTED, - TaskrouterListener, -} from '@tech-matters/serverless-helpers/taskrouter'; -import { HrmContact, PrepopulateForm } from '../hrm/populateHrmContactFormFromTask'; - -export const eventTypes: EventType[] = [ - /* RESERVATION_ACCEPTED */ -]; - -type EnvVars = { - TWILIO_WORKSPACE_SID: string; - CHAT_SERVICE_SID: string; - HRM_STATIC_KEY: string; -}; - -// Temporarily copied to this repo, will share the flex types when we move them into the same repo - -const BLANK_CONTACT: HrmContact = { - id: '', - timeOfContact: new Date().toISOString(), - taskId: null, - helpline: '', - rawJson: { - childInformation: {}, - callerInformation: {}, - caseInformation: {}, - callType: '', - contactlessTask: { - channel: 'web', - date: '', - time: '', - createdOnBehalfOf: '', - helpline: '', - }, - categories: {}, - }, - channelSid: '', - serviceSid: '', - channel: 'default', - createdBy: '', - createdAt: '', - updatedBy: '', - updatedAt: '', - queueName: '', - number: '', - conversationDuration: 0, - csamReports: [], - conversationMedia: [], -}; - -/** - * Checks the event type to determine if the listener should handle the event or not. - * If it returns true, the taskrouter will invoke this listener. - */ -export const shouldHandle = ({ - TaskAttributes: taskAttributesString, - TaskSid: taskSid, - EventType: eventType, -}: EventFields) => { - if (!eventTypes.includes(eventType)) return false; - - const { isContactlessTask, transferTargetType } = JSON.parse(taskAttributesString ?? '{}'); - - if (isContactlessTask) { - console.debug(`Task ${taskSid} is a contactless task, contact was already created in Flex.`); - return false; - } - - if (transferTargetType) { - console.debug( - `Task ${taskSid} was created to receive a ${transferTargetType} transfer. The original contact will be used so a new one will not be created.`, - ); - return false; - } - return true; -}; - -export const handleEvent = async ( - { getTwilioClient, HRM_STATIC_KEY, TWILIO_WORKSPACE_SID }: Context, - { TaskAttributes: taskAttributesString, TaskSid: taskSid, WorkerSid: workerSid }: EventFields, -) => { - const taskAttributes = taskAttributesString ? JSON.parse(taskAttributesString) : {}; - const { channelSid } = taskAttributes; - - const client = getTwilioClient(); - const serviceConfig = await client.flexApi.configuration.get().fetch(); - - const { - definitionVersion, - hrm_base_url: hrmBaseUrl, - hrm_api_version: hrmApiVersion, - form_definitions_version_url: configFormDefinitionsVersionUrl, - assets_bucket_url: assetsBucketUrl, - helpline_code: helplineCode, - channelType, - customChannelType, - feature_flags: { enable_backend_hrm_contact_creation: enableBackendHrmContactCreation }, - } = serviceConfig.attributes; - const formDefinitionsVersionUrl = - configFormDefinitionsVersionUrl || `${assetsBucketUrl}/form-definitions/${helplineCode}/v1`; - if (!enableBackendHrmContactCreation) { - console.debug( - `enable_backend_hrm_contact_creation is not set, the contact associated with task ${taskSid} will be created from Flex.`, - ); - return; - } - console.debug('Creating HRM contact for task', taskSid); - const hrmBaseAccountUrl = `${hrmBaseUrl}/${hrmApiVersion}/accounts/${serviceConfig.accountSid}`; - - const newContact: HrmContact = { - ...BLANK_CONTACT, - channel: (customChannelType || channelType) as HrmContact['channel'], - rawJson: { - definitionVersion, - ...BLANK_CONTACT.rawJson, - }, - twilioWorkerId: workerSid as HrmContact['twilioWorkerId'], - taskId: taskSid as HrmContact['taskId'], - channelSid: channelSid ?? '', - serviceSid: (channelSid && serviceConfig.chatServiceInstanceSid) ?? '', - // We set createdBy to the workerSid because the contact is 'created' by the worker who accepts the task - createdBy: workerSid as HrmContact['createdBy'], - }; - - const prepopulatePath = Runtime.getFunctions()['hrm/populateHrmContactFormFromTask'].path; - const { populateHrmContactFormFromTask } = require(prepopulatePath) as PrepopulateForm; - const populatedContact = await populateHrmContactFormFromTask( - taskAttributes, - newContact, - formDefinitionsVersionUrl, - ); - const options: RequestInit = { - method: 'POST', - body: JSON.stringify(populatedContact), - headers: { - 'Content-Type': 'application/json', - Authorization: `Basic ${HRM_STATIC_KEY}`, - }, - }; - const response = await fetch(`${hrmBaseAccountUrl}/contacts`, options); - if (!response.ok) { - console.error( - `Failed to create HRM contact for task ${taskSid} - status: ${response.status} - ${response.statusText}`, - await response.text(), - ); - return; - } - const { id }: HrmContact = await response.json(); - console.info(`Created HRM contact with id ${id} for task ${taskSid}`); - - const taskContext = client.taskrouter.v1.workspaces.get(TWILIO_WORKSPACE_SID).tasks.get(taskSid); - const currentTaskAttributes = (await taskContext.fetch()).attributes; // Less chance of race conditions if we fetch the task attributes again, still not the best... - const updatedAttributes = { - ...JSON.parse(currentTaskAttributes), - contactId: id.toString(), - }; - await taskContext.update({ attributes: JSON.stringify(updatedAttributes) }); -}; - -/** - * The taskrouter callback expects that all taskrouter listeners return - * a default object of type TaskrouterListener. - */ -const createHrmContactListener: TaskrouterListener = { - shouldHandle, - handleEvent, -}; - -export default createHrmContactListener;