diff --git a/.github/actions/run-e2e-tests/action.yml b/.github/actions/run-e2e-tests/action.yml index 3cdccaa5d..14be664bf 100644 --- a/.github/actions/run-e2e-tests/action.yml +++ b/.github/actions/run-e2e-tests/action.yml @@ -60,7 +60,8 @@ runs: shell: bash - name: Setup dependencies for playwright/browsers - uses: microsoft/playwright-github-action@v1 + shell: bash + run: npx playwright install --with-deps chromium - name: Install Playwright Browsers run: npm run postinstall diff --git a/.github/workflows/twilio-lambda-deploy.yml b/.github/workflows/twilio-lambda-deploy.yml index a9a4edbaf..f52402f27 100644 --- a/.github/workflows/twilio-lambda-deploy.yml +++ b/.github/workflows/twilio-lambda-deploy.yml @@ -133,7 +133,7 @@ jobs: uses: slackapi/slack-github-action@v1.25.0 with: channel-id: ${{ env.ASELO_DEPLOYS_CHANNEL_ID }} - slack-message: '`[HRM lambdas - ${{ matrix.lambda_path }}]` Deployment of ${{ github.ref_type }} `${{ github.ref_name }}` requested by `${{ github.triggering_actor }}` completed with SHA ${{ github.sha }} to region `${{ inputs.region }}`, environment `${{ inputs.environment }}` :rocket:.' + slack-message: '`[Twilio lambdas - ${{ matrix.lambda_path }}]` Deployment of ${{ github.ref_type }} `${{ github.ref_name }}` requested by `${{ github.triggering_actor }}` completed with SHA ${{ github.sha }} to region `${{ inputs.region }}`, environment `${{ inputs.environment }}` :rocket:.' env: SLACK_BOT_TOKEN: ${{ env.GITHUB_ACTIONS_SLACK_BOT_TOKEN }} if: ${{ inputs.send-slack-message != 'false' }} diff --git a/lambdas/account-scoped/package.json b/lambdas/account-scoped/package.json index 44e195b92..df5004c0a 100644 --- a/lambdas/account-scoped/package.json +++ b/lambdas/account-scoped/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "devDependencies": { "@types/aws-lambda": "^8.10.108", + "@types/lodash": "^4.17.13", "@types/node": "^18.16.2", "jest-each": "^29.5.0", "ts-node": "^10.9.1", @@ -15,6 +16,9 @@ }, "dependencies": { "@aws-sdk/client-ssm": "^3.716.0", + "@twilio-labs/serverless-runtime-types": "^4.0.1", + "date-fns": "^4.1.0", + "lodash": "^4.17.21", "twilio": "^5.4.0" } } diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts new file mode 100644 index 000000000..91d1ee2bf --- /dev/null +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -0,0 +1,175 @@ +/** + * 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 { + HrmContact, + populateHrmContactFormFromTask, +} from './populateHrmContactFormFromTask'; +import { registerTaskRouterEventHandler } from '../taskrouter/taskrouterEventHandler'; +import { EventType, RESERVATION_ACCEPTED } from '../taskrouter/eventTypes'; +import type { EventFields } from '../taskrouter'; +import twilio from 'twilio'; +import { AccountSID } from '../twilioTypes'; +import { getSsmParameter } from '../ssmCache'; + +export const eventTypes: EventType[] = [RESERVATION_ACCEPTED]; + +// 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: [], +}; + +export const handleEvent = async ( + { + TaskAttributes: taskAttributesString, + TaskSid: taskSid, + WorkerSid: workerSid, + }: EventFields, + accountSid: AccountSID, + client: twilio.Twilio, +): Promise => { + const taskAttributes = taskAttributesString ? JSON.parse(taskAttributesString) : {}; + const { channelSid, isContactlessTask, transferTargetType } = taskAttributes; + + if (isContactlessTask) { + console.debug( + `Task ${taskSid} is a contactless task, contact was already created in Flex.`, + ); + return; + } + + 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; + } + + const serviceConfig = await client.flexApi.v1.configuration.get().fetch(); + + const { + definitionVersion, + 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; + } + + const [hrmStaticKey, twilioWorkspaceSid] = await Promise.all([ + getSsmParameter(`/${process.env.NODE_ENV}/twilio/${accountSid}/static_key`), + getSsmParameter(`/${process.env.NODE_ENV}/twilio/${accountSid}/workspace_sid`), + ]); + const contactUrl = `${process.env.INTERNAL_HRM_URL}/internal/${hrmApiVersion}/accounts/${accountSid}/contacts`; + + console.debug('Creating HRM contact for task', taskSid, contactUrl); + + 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 populatedContact = await populateHrmContactFormFromTask( + taskAttributes, + newContact, + formDefinitionsVersionUrl, + ); + const options: RequestInit = { + method: 'POST', + body: JSON.stringify(populatedContact), + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${hrmStaticKey}`, + }, + }; + const response = await fetch(contactUrl, 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 } = (await response.json()) as HrmContact; + console.info(`Created HRM contact with id ${id} for task ${taskSid}`); + + const taskContext = client.taskrouter.v1.workspaces + .get(twilioWorkspaceSid) + .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) }); +}; + +registerTaskRouterEventHandler([RESERVATION_ACCEPTED], handleEvent); diff --git a/lambdas/account-scoped/src/hrm/populateHrmContactFormFromTask.ts b/lambdas/account-scoped/src/hrm/populateHrmContactFormFromTask.ts new file mode 100644 index 000000000..c2350de64 --- /dev/null +++ b/lambdas/account-scoped/src/hrm/populateHrmContactFormFromTask.ts @@ -0,0 +1,499 @@ +/** + * 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/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 59e5f8934..466080d0b 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -14,11 +14,11 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { validateWebhookRequest } from './validation/webhook'; +import { validateWebhookRequest } from './validation/twilioWebhook'; import { AccountScopedRoute, FunctionRoute, HttpRequest } from './httpTypes'; import { validateRequestMethod } from './validation/method'; import { isAccountSID } from './twilioTypes'; -import { handleTaskRouterEvent } from './taskrouterEventHandler'; +import { handleTaskRouterEvent } from './taskrouter'; /** * Super simple router sufficient for directly ported Twilio Serverless functions diff --git a/lambdas/account-scoped/src/taskrouter/eventFields.ts b/lambdas/account-scoped/src/taskrouter/eventFields.ts new file mode 100644 index 000000000..3c57e3b81 --- /dev/null +++ b/lambdas/account-scoped/src/taskrouter/eventFields.ts @@ -0,0 +1,59 @@ +/** + * 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 type { EventType } from './eventTypes'; + +export type EventFields = { + // Default fields + EventType: EventType; + AccountSid: string; + WorkspaceSid: string; + WorkspaceName: string; + EventDescription: string; + ResourceType: 'Task' | 'Reservation' | 'Worker' | 'Activity' | 'Workflow' | 'Workspace'; + ResourceSid: string; + Timestamp: string; + + // Task + TaskSid: string; + TaskAttributes: string; + TaskAge: number; + TaskPriority: number; + TaskAssignmentStatus: string; + TaskCanceledReason: string; + TaskCompletedReason: string; + + // TaskChannel + TaskChannelSid: string; + TaskChannelName: string; + TaskChannelUniqueName: string; + TaskChannelOptimizedRouting: boolean; + + // Worker + WorkerSid: string; + WorkerName: string; + WorkerAttributes: string; + WorkerActivitySid: string; + WorkerActivityName: string; + WorkerVersion: string; + WorkerTimeInPreviousActivity: number; + WorkerTimeInPreviousActivityMs: number; + WorkerPreviousActivitySid: string; + WorkerChannelAvailable: boolean; + WorkerChannelAvailableCapacity: number; + WorkerChannelPreviousCapacity: number; + WorkerChannelTaskCount: number; +}; diff --git a/lambdas/account-scoped/src/taskrouter/eventTypes.ts b/lambdas/account-scoped/src/taskrouter/eventTypes.ts new file mode 100644 index 000000000..8d71dc857 --- /dev/null +++ b/lambdas/account-scoped/src/taskrouter/eventTypes.ts @@ -0,0 +1,153 @@ +/** + * 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/. + */ + +/** + * Tasrouter EventTypes: https://www.twilio.com/docs/taskrouter/api/event/reference#event-types + */ + +// Activity +export const ACTIVITY_CREATED = 'activity.created'; +export const ACTIVITY_UPDATED = 'activity.updated'; +export const ACTIVITY_DELETED = 'activity.deleted'; + +// Reservation +export const RESERVATION_CREATED = 'reservation.created'; +export const RESERVATION_ACCEPTED = 'reservation.accepted'; +export const RESERVATION_REJECTED = 'reservation.rejected'; +export const RESERVATION_TIMEOUT = 'reservation.timeout'; +export const RESERVATION_CANCELED = 'reservation.canceled'; +export const RESERVATION_RESCINDED = 'reservation.rescinded'; +export const RESERVATION_COMPLETED = 'reservation.completed'; +export const RESERVATION_FAILED = 'reservation.failed'; +export const RESERVATION_WRAPUP = 'reservation.wrapup'; + +// Task +export const TASK_CREATED = 'task.created'; +export const TASK_UPDATED = 'task.updated'; +export const TASK_CANCELED = 'task.canceled'; +export const TASK_WRAPUP = 'task.wrapup'; +export const TASK_COMPLETED = 'task.completed'; +export const TASK_DELETED = 'task.deleted'; +export const TASK_SYSTEM_DELETED = 'task.system-deleted'; +export const TASK_TRANSFER_INITIATED = 'task.transfer-initiated'; +export const TASK_TRANSFER_ATTEMPT_FAILED = 'task.transfer-attempt-failed'; +export const TASK_TRANSFER_FAILED = 'task.transfer-failed'; +export const TASK_TRANSFER_CANCELED = 'task.transfer-canceled'; +export const TASK_TRANSFER_COMPLETED = 'task.transfer-completed'; + +// Task Channel +export const TASK_CHANNEL_CREATED = 'task-channel.created'; +export const TASK_CHANNEL_UPDATED = 'task-channel.updated'; +export const TASK_CHANNEL_DELETED = 'task-channel.deleted'; + +// Task Queue +export const TASK_QUEUE_CREATED = 'task-queue.created'; +export const TASK_QUEUE_DELETED = 'task-queue.deleted'; +export const TASK_QUEUE_ENTERED = 'task-queue.entered'; +export const TASK_QUEUE_TIMEOUT = 'task-queue.timeout'; +export const TASK_QUEUE_MOVED = 'task-queue.moved'; +export const TASK_QUEUE_EXPRESSION_UPDATED = 'task-queue.expression.updated'; + +// Worker +export const WORKER_CREATED = 'worker.created'; +export const WORKER_ACTIVITY_UPDATE = 'worker.activity.update'; +export const WORKER_ATTRIBUTES_UPDATE = 'worker.attributes.update'; +export const WORKER_CAPACITY_UPDATE = 'worker.capacity.update'; +export const WORKER_CHANNEL_AVAILABILITY_UPDATE = 'worker.channel.availability.update'; +export const WORKER_DELETED = 'worker.deleted'; + +// Workflow +export const WORKFLOW_CREATED = 'workflow.created'; +export const WORKFLOW_UPDATED = 'workflow.updated'; +export const WORKFLOW_DELETED = 'workflow.deleted'; +export const WORKFLOW_TARGET_MATCHED = 'workflow.target-matched'; +export const WORKFLOW_ENTERED = 'workflow.entered'; +export const WORKFLOW_TIMEOUT = 'workflow.timeout'; +export const WORKFLOW_SKIPPED = 'workflow.skipped'; + +// Workspace +export const WORKSPACE_CREATED = 'workspace.created'; +export const WORKSPACE_UPDATED = 'workspace.updated'; +export const WORKSPACE_DELETED = 'workspace.deleted'; + +export const eventTypes = { + // Activity + ACTIVITY_CREATED, + ACTIVITY_UPDATED, + ACTIVITY_DELETED, + + // Reservation + RESERVATION_CREATED, + RESERVATION_ACCEPTED, + RESERVATION_REJECTED, + RESERVATION_TIMEOUT, + RESERVATION_CANCELED, + RESERVATION_RESCINDED, + RESERVATION_COMPLETED, + RESERVATION_FAILED, + RESERVATION_WRAPUP, + + // Task + TASK_CREATED, + TASK_UPDATED, + TASK_CANCELED, + TASK_WRAPUP, + TASK_COMPLETED, + TASK_DELETED, + TASK_SYSTEM_DELETED, + TASK_TRANSFER_INITIATED, + TASK_TRANSFER_ATTEMPT_FAILED, + TASK_TRANSFER_FAILED, + TASK_TRANSFER_CANCELED, + TASK_TRANSFER_COMPLETED, + + // Task Channel + TASK_CHANNEL_CREATED, + TASK_CHANNEL_UPDATED, + TASK_CHANNEL_DELETED, + + // Task Queue + TASK_QUEUE_CREATED, + TASK_QUEUE_DELETED, + TASK_QUEUE_ENTERED, + TASK_QUEUE_TIMEOUT, + TASK_QUEUE_MOVED, + TASK_QUEUE_EXPRESSION_UPDATED, + + // Worker + WORKER_CREATED, + WORKER_ACTIVITY_UPDATE, + WORKER_ATTRIBUTES_UPDATE, + WORKER_CAPACITY_UPDATE, + WORKER_CHANNEL_AVAILABILITY_UPDATE, + WORKER_DELETED, + + // Workflow + WORKFLOW_CREATED, + WORKFLOW_UPDATED, + WORKFLOW_DELETED, + WORKFLOW_TARGET_MATCHED, + WORKFLOW_ENTERED, + WORKFLOW_TIMEOUT, + WORKFLOW_SKIPPED, + + // Workspace + WORKSPACE_CREATED, + WORKSPACE_UPDATED, + WORKSPACE_DELETED, +} as const; + +export type EventType = (typeof eventTypes)[keyof typeof eventTypes]; diff --git a/lambdas/account-scoped/src/taskrouter/index.ts b/lambdas/account-scoped/src/taskrouter/index.ts new file mode 100644 index 000000000..3e133e28a --- /dev/null +++ b/lambdas/account-scoped/src/taskrouter/index.ts @@ -0,0 +1,22 @@ +/** + * 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 '../hrm/createHrmContactTaskRouterListener'; + +export { handleTaskRouterEvent } from './taskrouterEventHandler'; + +export { eventTypes, EventType } from './eventTypes'; +export { EventFields } from './eventFields'; diff --git a/lambdas/account-scoped/src/taskrouterEventHandler.ts b/lambdas/account-scoped/src/taskrouter/taskrouterEventHandler.ts similarity index 63% rename from lambdas/account-scoped/src/taskrouterEventHandler.ts rename to lambdas/account-scoped/src/taskrouter/taskrouterEventHandler.ts index 3234b72bb..154096503 100644 --- a/lambdas/account-scoped/src/taskrouterEventHandler.ts +++ b/lambdas/account-scoped/src/taskrouter/taskrouterEventHandler.ts @@ -14,18 +14,24 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { AccountScopedHandler } from './httpTypes'; -import { AccountSID } from './twilioTypes'; -import { newOk } from './Result'; +import { AccountScopedHandler } from '../httpTypes'; +import { AccountSID } from '../twilioTypes'; +import { newOk } from '../Result'; +import { EventType } from './eventTypes'; +import twilio, { Twilio } from 'twilio'; +import { getSsmParameter } from '../ssmCache'; -const eventHandlers: Record< - string, - ((event: any, accountSid: AccountSID) => Promise)[] -> = {}; +export type TaskRouterEventHandler = ( + event: any, + accountSid: AccountSID, + twilioClient: Twilio, +) => Promise; + +const eventHandlers: Record = {}; export const registerTaskRouterEventHandler = ( - eventTypes: string[], - handler: (event: any, accountSid: AccountSID) => Promise, + eventTypes: EventType[], + handler: TaskRouterEventHandler, ) => { for (const eventType of eventTypes) { if (!eventHandlers[eventType]) { @@ -43,7 +49,15 @@ export const handleTaskRouterEvent: AccountScopedHandler = async ( console.info( `Handling task router event: ${body.EventType} for account: ${accountSid} - executing ${handlers.length} registered handlers.`, ); - await Promise.all(handlers.map(handler => handler(body, accountSid))); + await Promise.all( + handlers.map(async handler => { + const authToken = await getSsmParameter( + `/${process.env.NODE_ENV}/twilio/${accountSid}/auth_token`, + ); + const client = await twilio(accountSid, authToken); + return handler(body, accountSid, client); + }), + ); console.debug( `Successfully executed ${handlers.length} registered handlers task router event: ${body.EventType} for account: ${accountSid}.`, ); diff --git a/lambdas/account-scoped/src/validation/webhook.ts b/lambdas/account-scoped/src/validation/twilioWebhook.ts similarity index 100% rename from lambdas/account-scoped/src/validation/webhook.ts rename to lambdas/account-scoped/src/validation/twilioWebhook.ts diff --git a/lambdas/package-lock.json b/lambdas/package-lock.json index 3111e15da..8c48f50d6 100644 --- a/lambdas/package-lock.json +++ b/lambdas/package-lock.json @@ -55,10 +55,14 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-ssm": "^3.716.0", + "@twilio-labs/serverless-runtime-types": "^4.0.1", + "date-fns": "^4.1.0", + "lodash": "^4.17.21", "twilio": "^5.4.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.108", + "@types/lodash": "^4.17.13", "@types/node": "^18.16.2", "jest-each": "^29.5.0", "ts-node": "^10.9.1", @@ -3152,6 +3156,34 @@ "integrity": "sha512-5xxU8vVs9/FNcvm3gE07fPbn9tl6tqGGWA9tSlwsUEkBxtRnTsNmwrV8gasZ9F/EobaSv9+nu8AxUKccw77JpQ==", "dev": true }, + "node_modules/@twilio-labs/serverless-runtime-types": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@twilio-labs/serverless-runtime-types/-/serverless-runtime-types-4.0.1.tgz", + "integrity": "sha512-JepyB0ggxmAKISKHfxZa8UyLPdKsFaoD4Tk/8TY4qxMusJVnSEZZQ8ecMgVhmlXINVb5m2Ybw8N+iXL10DticQ==", + "dependencies": { + "@types/express": "^4.17.21", + "@types/qs": "^6.9.4", + "twilio": "^4.23.0" + } + }, + "node_modules/@twilio-labs/serverless-runtime-types/node_modules/twilio": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.23.0.tgz", + "integrity": "sha512-LdNBQfOe0dY2oJH2sAsrxazpgfFQo5yXGxe96QA8UWB5uu+433PrUbkv8gQ5RmrRCqUTPQ0aOrIyAdBr1aB03Q==", + "dependencies": { + "axios": "^1.6.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "qs": "^6.9.4", + "scmp": "^2.1.0", + "url-parse": "^1.5.9", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@types/aws-lambda": { "version": "8.10.137", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.137.tgz", @@ -3199,6 +3231,45 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3208,6 +3279,11 @@ "@types/node": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3254,11 +3330,21 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, "node_modules/@types/node": { "version": "18.19.67", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.67.tgz", "integrity": "sha512-wI8uHusga+0ZugNp0Ol/3BqQfEcCCNfojtO6Oou9iVNGPTL6QNSdnUdqq85fRgIorLhLMuPIKpsN98QE9Nh+KQ==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3279,6 +3365,16 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "dev": true }, + "node_modules/@types/qs": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, "node_modules/@types/redis": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-4.0.11.tgz", @@ -3295,6 +3391,25 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4302,6 +4417,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -7292,8 +7416,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -8214,6 +8337,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8307,6 +8435,11 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -9286,8 +9419,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/update-browserslist-db": { "version": "1.0.13", @@ -9328,6 +9460,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -11740,9 +11881,13 @@ "version": "file:account-scoped", "requires": { "@aws-sdk/client-ssm": "^3.716.0", + "@twilio-labs/serverless-runtime-types": "^4.0.1", "@types/aws-lambda": "^8.10.108", + "@types/lodash": "^4.17.13", "@types/node": "^18.16.2", + "date-fns": "^4.1.0", "jest-each": "^29.5.0", + "lodash": "^4.17.21", "ts-node": "^10.9.1", "twilio": "^5.4.0", "undici": "^5.28.3" @@ -11865,6 +12010,33 @@ "integrity": "sha512-5xxU8vVs9/FNcvm3gE07fPbn9tl6tqGGWA9tSlwsUEkBxtRnTsNmwrV8gasZ9F/EobaSv9+nu8AxUKccw77JpQ==", "dev": true }, + "@twilio-labs/serverless-runtime-types": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@twilio-labs/serverless-runtime-types/-/serverless-runtime-types-4.0.1.tgz", + "integrity": "sha512-JepyB0ggxmAKISKHfxZa8UyLPdKsFaoD4Tk/8TY4qxMusJVnSEZZQ8ecMgVhmlXINVb5m2Ybw8N+iXL10DticQ==", + "requires": { + "@types/express": "^4.17.21", + "@types/qs": "^6.9.4", + "twilio": "^4.23.0" + }, + "dependencies": { + "twilio": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.23.0.tgz", + "integrity": "sha512-LdNBQfOe0dY2oJH2sAsrxazpgfFQo5yXGxe96QA8UWB5uu+433PrUbkv8gQ5RmrRCqUTPQ0aOrIyAdBr1aB03Q==", + "requires": { + "axios": "^1.6.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "qs": "^6.9.4", + "scmp": "^2.1.0", + "url-parse": "^1.5.9", + "xmlbuilder": "^13.0.2" + } + } + } + }, "@types/aws-lambda": { "version": "8.10.137", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.137.tgz", @@ -11912,6 +12084,45 @@ "@babel/types": "^7.20.7" } }, + "@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -11921,6 +12132,11 @@ "@types/node": "*" } }, + "@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -11967,11 +12183,21 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true + }, + "@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, "@types/node": { "version": "18.19.67", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.67.tgz", "integrity": "sha512-wI8uHusga+0ZugNp0Ol/3BqQfEcCCNfojtO6Oou9iVNGPTL6QNSdnUdqq85fRgIorLhLMuPIKpsN98QE9Nh+KQ==", - "dev": true, "requires": { "undici-types": "~5.26.4" } @@ -11992,6 +12218,16 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "dev": true }, + "@types/qs": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==" + }, + "@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, "@types/redis": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-4.0.11.tgz", @@ -12007,6 +12243,25 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "requires": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -12694,6 +12949,11 @@ "is-data-view": "^1.0.1" } }, + "date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==" + }, "dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -14892,8 +15152,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.includes": { "version": "4.3.0", @@ -15584,6 +15843,11 @@ "side-channel": "^1.0.6" } }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -15650,6 +15914,11 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -16337,8 +16606,7 @@ "undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "update-browserslist-db": { "version": "1.0.13", @@ -16359,6 +16627,15 @@ "punycode": "^2.1.0" } }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",