diff --git a/Makefile b/Makefile index 6ccec70..8149fb8 100644 --- a/Makefile +++ b/Makefile @@ -51,9 +51,13 @@ send-email: send-sms: npm run send-test-sms -.PHONY: send-dispatch ## Send an email or sms with the higher-level dispatch function (for testing). -send-dispatch: - npm run send-test-dispatch +.PHONY: send-dispatch-email ## Send an email with the higher-level dispatch function (for testing). +send-dispatch-email: + TEST_DISPATCH_CHANNEL='email' npm run send-test-dispatch + +.PHONY: send-dispatch-sms ## Send an sms with the higher-level dispatch function (for testing). +send-dispatch-sms: + TEST_DISPATCH_CHANNEL='sms' npm run send-test-dispatch #### diff --git a/docs/inbound-sms.md b/docs/inbound-sms.md new file mode 100644 index 0000000..1c5854f --- /dev/null +++ b/docs/inbound-sms.md @@ -0,0 +1,56 @@ +# Inbound SMS + +Gov Flow supports multiple "inbound channels" for service requests. The basic inbound channel is submitting data via a POST request, as generally done from a web form (and, we call this channel `webform` in the data model ([ref.](https://github.com/govflow/govflow/blob/main/src/core/service-requests/models.ts#L20))). + +Gov Flow also supports SMS as a channel for service requests, and in this implementation, an SMS can be sent to a special email address, and in turn, data from that email gets sent to Gov Flow, processed, and turned into a service request. There are several aspects of this integration to create structured data out of an SMS, and here we will go over all the details. + +## Configure a Twilio Number for the GovFlow instance + +At the level of the GovFlow instance (the server), we need one Twilio Number configured, and set at `TWILIO_FROM_PHONE`. + +This number is used to emit SMS messages for Service Request submissions as a fallback, and irregardless of whether a given Jurisdiction has opted in to Inbound SMS ot Two-way SMS. Think if it like `webmaster@localhost` for SMS in GovFlow. + +This number needs to be configured to send messages (default when you acquire a Twilio Number), and also it is highly recommended to provide an autoresponder from this number so if anyone attempts to respond back to an SMS, it is clear that it is a "no reply" number. + +The core configuration for Twilio is to set `TWILIO_ACCOUNT_SID ` and `TWILIO_AUTH_TOKEN` with valid credentials from a Twilio account, in order to interact with Twilio. + +### Set up an auto response with Studio Flow + +[Studio Flow](https://console.twilio.com/us1/develop/studio?frameUrl=%2Fconsole%2Fstudio%2Fdashboard%3Fx-target-region%3Dus1) is Twilio's UI for creating logic around events in there system. We can use it to set an auto response on our GovFlow instance number. + +- Create a new flow (e.g.: called "GovFlow Instance Auto Response") +- Choose the "Message Auto Responder" template +- When the editor opens up, it creates a widget with a default name and message body. Edit it as desired. (e.g: "This number does not accept replies. Please contact the Jursidiction where you submitted your request directly.") +- After you save your widget, click on the "trigger" component, and then, on the right had side edit panel, you will see a link "Using this flow with a Phoen Number". Click that link and follow the stesp to associate it with your GovFlow Instance Number +- Click on the Twilio Number that is your instance number, go down to messaging, and change "A Message Comes in" to Studio Flow and select to Flow you just created. +- Now, this number will be used for sending system messages, but if anyone tries to respond to such messages, they will receive a response back "This number does not accept replies. Please contact the Jursidiction where you submitted your request directly." + +## Configure a Twilio Number for a Jurisidiction + +Ok, so now with the basics of what the GovFlow server needs done, we can move onto Jurisdiction-level Twilio Number configuration for inbound SMS. + +The core configuration for Twilio is to set `TWILIO_ACCOUNT_SID ` and `TWILIO_AUTH_TOKEN` with valid credentials from a Twilio account. + +The Twilio setup steps are: + +- Create a new number, and give it a friendly name (e.g.: "GovFlow Demo") +- As with the instance-level number, create an auto response (as this is one-way communication, we want it to be clear to submitters). + - It is recommended to create a generic "jursidiction no reply" response. You can do that by duplicating the "GovFlow Instance Auto Response" we created above, call it "GovFlow Jurisdiction No Reply" and write your message. + - We will attach all Jurisdiction Numbers to this by default, and only change that if (i) a Jurisdiction wants a custom message, or (ii), the **common case**, if two-way communication via SMS is enabled. + +Next, we need to configure the use of this Number for the Jurisdiction in two places: + +1. `Jurisdiction.sendFromPhone` - This needs to be set with the new number. When this is set, outgoing messages for the jurisdiction will be sent from this number rather than `TWILIO_FROM_PHONE`. This also includes the sending of messages when `Jursidcition.replyToServiceRequestEnabled` is enabled for two-way communication. +2. Creating a new `InboundMap` record to route incoming SMS from this number to the correct Jurisdiction (this is the equivalent of how InboundMap is used for [Inbound Email](./inbound-email.md)). + - Note that we **don't** simply use `Jurisdiction.sendFromPhone` because it is possible to create any number of InboundMap records with different Twilio Numbers and/or with different rules. However, the happy path for small Jurisdictions will be to simply create a single InboundMap record associating the Twilio Number and the Jurisdiction, and have all inbound requests come via it. + +```http +POST {{ host }}/communications/create-map?jurisdictionId={{ jurisdictionId }} +content-type: application/json + +{ + "jurisdictionId": "{{ jurisdictionId }}", + "channel": "sms", + "id": "+1-111-11111" +} +``` diff --git a/src/cli/send-test-dispatch.ts b/src/cli/send-test-dispatch.ts index e0553e3..4e203e3 100644 --- a/src/cli/send-test-dispatch.ts +++ b/src/cli/send-test-dispatch.ts @@ -18,7 +18,7 @@ import { DispatchConfigAttributes } from '../types'; } = app.config; const { communicationRepository, emailStatusRepository } = app.repositories; const dispatchConfig = { - channel: 'email', // can manually change to sms to test that + channel: process.env.TEST_DISPATCH_CHANNEL, sendGridApiKey: sendGridApiKey as string, toEmail: testToEmail as string, fromEmail: sendGridFromEmail as string, diff --git a/src/core/communications/helpers.ts b/src/core/communications/helpers.ts index 8abac27..cb3c4eb 100644 --- a/src/core/communications/helpers.ts +++ b/src/core/communications/helpers.ts @@ -9,7 +9,7 @@ import striptags from 'striptags'; import { sendEmail } from '../../email'; import logger from '../../logging'; import { sendSms } from '../../sms'; -import { CommunicationAttributes, DispatchConfigAttributes, DispatchPayloadAttributes, EmailEventAttributes, ICommunicationRepository, IEmailStatusRepository, InboundEmailDataToRequestAttributes, InboundMapInstance, JurisdictionAttributes, ParsedForwardedData, ParsedServiceRequestAttributes, PublicId, ServiceRequestAttributes, TemplateConfigAttributes, TemplateConfigContextAttributes } from '../../types'; +import { ChannelType, CommunicationAttributes, DispatchConfigAttributes, DispatchPayloadAttributes, EmailEventAttributes, ICommunicationRepository, IEmailStatusRepository, InboundEmailDataToRequestAttributes, InboundMapInstance, InboundSmsDataToRequestAttributes, JurisdictionAttributes, ParsedForwardedData, ParsedServiceRequestAttributes, PublicId, ServiceRequestAttributes, TemplateConfigAttributes, TemplateConfigContextAttributes } from '../../types'; import { SERVICE_REQUEST_CLOSED_STATES } from '../service-requests'; import { InboundMapRepository } from './repositories'; @@ -32,6 +32,10 @@ export function makeRequestURL(appClientUrl: string, appClientRequestsPath: stri export async function loadTemplate(templateName: string, templateContext: TemplateConfigContextAttributes, isBody = true): Promise { const filepath = path.resolve(`${__dirname}/templates/${templateName}.txt`); const [templateType, ..._rest] = templateName.split('.'); + let lineBreak = "\n"; + if (templateType === "email") { + lineBreak = '
'; + } try { await fs.access(filepath, fsConstants.R_OK | fsConstants.W_OK); } catch (error) { @@ -46,12 +50,12 @@ export async function loadTemplate(templateName: string, templateContext: Templa if (isBody) { const poweredBy = path.resolve(`${__dirname}/templates/${templateType}.powered-by.txt`); const poweredByBuffer = await fs.readFile(poweredBy); - appendString = `
${poweredByBuffer.toString()}
`; + appendString = `${lineBreak}${poweredByBuffer.toString()}${lineBreak}`; const unsubscribe = path.resolve(`${__dirname}/templates/${templateType}.unsubscribe.txt`); const unsubscribeBuffer = await fs.readFile(unsubscribe); replyAboveLine = templateContext.jurisdictionReplyToServiceRequestEnabled - ? `${emailBodySanitizeLine}

` : ''; - appendString = `${appendString}
${unsubscribeBuffer.toString()}
`; + ? `${emailBodySanitizeLine}${lineBreak}${lineBreak}` : ''; + appendString = `${appendString}${lineBreak}${unsubscribeBuffer.toString()}${lineBreak}`; } const fullTemplateString = `${replyAboveLine}${templateString}${appendString}`; @@ -217,9 +221,8 @@ export function extractToEmail(inboundEmailDomain: string, headers: string, toEm return address as addrs.ParsedMailbox } -export async function findIdentifiers(toEmail: addrs.ParsedMailbox, InboundMap: InboundMapRepository): Promise { - const { local } = toEmail; - const record = await InboundMap.findOne(local) as InboundMapInstance; +export async function findIdentifiers(id: string, channel: ChannelType, InboundMap: InboundMapRepository): Promise { + const record = await InboundMap.findOne(id, channel) as InboundMapInstance; return record; } @@ -241,30 +244,64 @@ export function extractDescriptionFromInboundEmail(emailSubject: string, emailBo return `${prefix}${cleanText}`; } -export function extractPublicIdFromInboundEmail(emailSubject: string): string | undefined { +export function _extractPublicIdFromText(text: string): string | undefined { let publicId; - const match = emailSubject.match(publicIdSubjectLinePattern); + const match = text.match(publicIdSubjectLinePattern); if (match && match.length == 2) { publicId = match[1]; } return publicId; } +export function extractPublicIdFromInboundEmail(emailSubject: string): string | undefined { + return _extractPublicIdFromText(emailSubject); +} + +export function extractPublicIdFromInboundSms(smsBody: string): string | undefined { + return _extractPublicIdFromText(smsBody); +} + export function extractCreatedAtFromInboundEmail(headers: string): Date { const dateStr = extractDate(headers); return new Date(dateStr); } +export async function extractServiceRequestfromInboundSms(data: InboundSmsDataToRequestAttributes, InboundMap: InboundMapRepository): + Promise<[ParsedServiceRequestAttributes, PublicId]> { + const { To, From, Body } = data; + const inputChannel = 'sms'; + + const publicId = extractPublicIdFromInboundSms(Body); + const { jurisdictionId, departmentId, staffUserId, serviceRequestId, serviceId } = await findIdentifiers( + To, inputChannel, InboundMap + ); + return [{ + jurisdictionId, + departmentId, + assignedTo: staffUserId, + serviceRequestId, + serviceId, + firstName: '', + lastName: '', + email: '', + phone: From, + description: Body, + inputChannel, + createdAt: new Date(), + }, publicId]; +} + export async function extractServiceRequestfromInboundEmail(data: InboundEmailDataToRequestAttributes, inboundEmailDomain: string, InboundMap: InboundMapRepository): Promise<[ParsedServiceRequestAttributes, PublicId]> { let firstName = '', lastName = '', email = ''; + const phone = ''; const inputChannel = 'email'; const { to, cc, bcc, from, headers } = data; let { subject, text } = data; let fromEmail = extractFromEmail(from); const toEmail = extractToEmail(inboundEmailDomain, headers, to, cc, bcc); const { jurisdictionId, departmentId, staffUserId, serviceRequestId, serviceId } = await findIdentifiers( - toEmail, InboundMap + toEmail.local, inputChannel, InboundMap ); const maybeForwarded = extractForwardDataFromEmail(text, subject); @@ -294,6 +331,7 @@ export async function extractServiceRequestfromInboundEmail(data: InboundEmailDa firstName, lastName, email, + phone, description, inputChannel, createdAt diff --git a/src/core/communications/models.ts b/src/core/communications/models.ts index 16ef347..14045dc 100644 --- a/src/core/communications/models.ts +++ b/src/core/communications/models.ts @@ -106,6 +106,11 @@ export const InboundMapModel: ModelDefinition = { allowNull: false, primaryKey: true, }, + channel: { + type: DataTypes.ENUM('email', 'sms'), + allowNull: false, + defaultValue: 'email', + }, staffUserId: { // is not a foreignKey as commonly customised type: DataTypes.STRING, allowNull: true, @@ -137,6 +142,10 @@ export const InboundMapModel: ModelDefinition = { { unique: true, fields: ['id', 'jurisdictionId', 'serviceRequestId'] + }, + { + unique: true, + fields: ['id', 'channel', 'jurisdictionId'] } ] } diff --git a/src/core/communications/repositories.ts b/src/core/communications/repositories.ts index 30652e0..8d01807 100644 --- a/src/core/communications/repositories.ts +++ b/src/core/communications/repositories.ts @@ -3,6 +3,7 @@ import { appIds } from '../../registry/service-identifiers'; import type { AppConfig, ChannelIsAllowed, ChannelStatusCreateAttributes, ChannelStatusInstance, + ChannelType, CommunicationAttributes, CommunicationCreateAttributes, CommunicationInstance, @@ -129,10 +130,10 @@ export class InboundMapRepository implements IInboundMapRepository { return await InboundMap.create(data) as InboundMapInstance; } - async findOne(id: string): Promise { + async findOne(id: string, channel: ChannelType): Promise { const { InboundMap } = this.models; const record = await InboundMap.findOne( - { where: { id }, order: [['createdAt', 'DESC']] } + { where: { id, channel }, order: [['createdAt', 'DESC']] } ) as InboundMapInstance | null; return record; } diff --git a/src/core/communications/routes.ts b/src/core/communications/routes.ts index 024d922..9d2fc58 100644 --- a/src/core/communications/routes.ts +++ b/src/core/communications/routes.ts @@ -9,6 +9,23 @@ import { EMAIL_EVENT_IGNORE } from './models'; export const communicationsRouter = Router(); +// public route for web hook integration +communicationsRouter.post('/inbound/sms', multer().none(), wrapHandler(async (req: Request, res: Response) => { + const { jurisdictionRepository } = res.app.repositories; + const { outboundMessageService, inboundMessageService } = res.app.services; + const [record, recordCreated] = await inboundMessageService.createServiceRequest(req.body); + const jurisdiction = await jurisdictionRepository.findOne(record.jurisdictionId) as JurisdictionAttributes; + + let eventName = 'serviceRequestCreate'; + if (recordCreated) { + GovFlowEmitter.emit(eventName, jurisdiction, record, outboundMessageService); + } else { + eventName = 'serviceRequestCommentBroadcast'; + GovFlowEmitter.emit(eventName, jurisdiction, record, outboundMessageService); + } + res.status(200).send({ data: { status: 200, message: "Received inbound SMS" } }); +})) + // public route for web hook integration communicationsRouter.post('/inbound/email', multer().none(), wrapHandler(async (req: Request, res: Response) => { const { jurisdictionRepository } = res.app.repositories; diff --git a/src/core/communications/services.ts b/src/core/communications/services.ts index 583b089..6baea44 100644 --- a/src/core/communications/services.ts +++ b/src/core/communications/services.ts @@ -9,13 +9,16 @@ import type { IInboundMessageService, InboundEmailDataAttributes, InboundMapAttributes, + InboundSmsDataAttributes, IOutboundMessageService, JurisdictionAttributes, + ParsedServiceRequestAttributes, + PublicId, RecipientAttributes, Repositories, ServiceRequestAttributes, ServiceRequestCommentAttributes, ServiceRequestInstance, StaffUserAttributes } from '../../types'; -import { canSubmitterComment, dispatchMessage, extractServiceRequestfromInboundEmail, getReplyToEmail, getSendFromEmail, makeRequestURL } from './helpers'; +import { canSubmitterComment, dispatchMessage, extractServiceRequestfromInboundEmail, extractServiceRequestfromInboundSms, getReplyToEmail, getSendFromEmail, makeRequestURL } from './helpers'; @injectable() export class OutboundMessageService implements IOutboundMessageService { @@ -460,28 +463,55 @@ export class InboundMessageService implements IInboundMessageService { this.config = config; } - async createServiceRequest(inboundEmailData: InboundEmailDataAttributes): + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inboundIsEmail(data: any): data is InboundEmailDataAttributes { + const valids = ['to', 'from', 'subject', 'text', 'envelope', 'dkim', 'SPF']; + const properties = Object.keys(data) + return valids.every(value => properties.includes(value)) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inboundIsSms(data: any): data is InboundSmsDataAttributes { + const valids = ['To', 'From', 'Body', 'MessageSid', 'AccountSid', 'SmsMessageSid', 'MessagingServiceSid']; + const properties = Object.keys(data) + return valids.every(value => properties.includes(value)) + } + + async createServiceRequest(inboundData: InboundEmailDataAttributes | InboundSmsDataAttributes): Promise<[ServiceRequestAttributes, boolean]> { const { serviceRequestRepository, staffUserRepository, inboundMapRepository } = this.repositories; - const { subject, to, cc, bcc, from, text, headers } = inboundEmailData; + const { inboundEmailDomain } = this.config; let intermediateRecord: ServiceRequestAttributes; let recordCreated = true; - const [cleanedData, publicId] = await extractServiceRequestfromInboundEmail( - { subject, to, cc, bcc, from, text, headers }, inboundEmailDomain, inboundMapRepository - ); + let cleanedData: ParsedServiceRequestAttributes; + let publicId: PublicId; + + if (this.inboundIsEmail(inboundData)) { + const { subject, to, cc, bcc, from, text, headers } = inboundData; + [cleanedData, publicId] = await extractServiceRequestfromInboundEmail( + { subject, to, cc, bcc, from, text, headers }, inboundEmailDomain, inboundMapRepository + ); + } else if (this.inboundIsSms(inboundData)) { + const { To, From, Body } = inboundData; + [cleanedData, publicId] = await extractServiceRequestfromInboundSms( + { To, From, Body }, inboundMapRepository + ); + } else { + throw Error("Received an invalid inbound data payload"); + } + if (publicId || cleanedData.serviceRequestId) { const [staffUsers, _count] = await staffUserRepository.findAll( cleanedData.jurisdictionId, { whereParams: { isAdmin: true } } ); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const staffEmails = staffUsers.map((user: StaffUserInstance) => { return user.email }) as string[]; + const staffEmails = staffUsers.map((user: StaffUserAttributes) => { return user.email }) as string[]; let addedBy = '__SUBMITTER__'; if (staffEmails.includes(cleanedData.email)) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - addedBy = _.find(staffUsers, (u) => { return u.email === cleanedData.email }).id + const staffUserMatch = _.find( + staffUsers, (u) => { return u.email === cleanedData.email } + ) as StaffUserAttributes; + addedBy = staffUserMatch.id; } if (cleanedData.serviceRequestId) { intermediateRecord = await serviceRequestRepository.findOne( diff --git a/src/core/jurisdictions/models.ts b/src/core/jurisdictions/models.ts index e020c6f..e09fdf4 100644 --- a/src/core/jurisdictions/models.ts +++ b/src/core/jurisdictions/models.ts @@ -28,6 +28,10 @@ export const JurisdictionModel: ModelDefinition = { type: DataTypes.BOOLEAN, defaultValue: false, }, + sendFromPhone: { + allowNull: true, + type: DataTypes.STRING, + }, sendFromEmail: { allowNull: true, type: DataTypes.STRING, diff --git a/src/core/jurisdictions/repositories.ts b/src/core/jurisdictions/repositories.ts index d093dfe..17dba95 100644 --- a/src/core/jurisdictions/repositories.ts +++ b/src/core/jurisdictions/repositories.ts @@ -42,6 +42,7 @@ export class JurisdictionRepository implements IJurisdictionRepository { 'email', 'sendFromEmail', 'sendFromEmailVerified', + 'sendFromPhone', 'replyToEmail', 'replyToServiceRequestEnabled', 'broadcastToSubmitterOnRequestClosed', diff --git a/src/migrations/23_add_channel_to_inbound_map.ts b/src/migrations/23_add_channel_to_inbound_map.ts new file mode 100644 index 0000000..4129c1c --- /dev/null +++ b/src/migrations/23_add_channel_to_inbound_map.ts @@ -0,0 +1,16 @@ +import type { QueryInterface } from 'sequelize'; +import { DataTypes } from 'sequelize'; + +export async function up({ context: queryInterface }: Record): Promise { + await queryInterface.addColumn('InboundMap', 'channel', { + type: DataTypes.ENUM('email', 'sms'), + allowNull: false, + defaultValue: 'email', + }); + await queryInterface.addIndex('InboundMap', ['id', 'channel']); +} + +export async function down({ context: queryInterface }: Record): Promise { + await queryInterface.removeColumn('InboundMap', 'channel'); + await queryInterface.removeIndex('InboundMap', ['id', 'channel']); +} diff --git a/src/migrations/24_jurisdiction_send_from_phone.ts b/src/migrations/24_jurisdiction_send_from_phone.ts new file mode 100644 index 0000000..968ea9e --- /dev/null +++ b/src/migrations/24_jurisdiction_send_from_phone.ts @@ -0,0 +1,13 @@ +import type { QueryInterface } from 'sequelize'; +import { DataTypes } from 'sequelize'; + +export async function up({ context: queryInterface }: Record): Promise { + await queryInterface.addColumn('Jurisdiction', 'sendFromPhone', { + allowNull: true, + type: DataTypes.STRING, + }); +} + +export async function down({ context: queryInterface }: Record): Promise { + await queryInterface.removeColumn('Jurisdiction', 'sendFromPhone'); +} diff --git a/src/tools/fake-data-generator.ts b/src/tools/fake-data-generator.ts index 9081732..824a277 100644 --- a/src/tools/fake-data-generator.ts +++ b/src/tools/fake-data-generator.ts @@ -124,8 +124,15 @@ function makeDepartment(options: Partial) { } function makeInboundMap(options: Partial) { + let id = faker.datatype.uuid(); + let channel = 'email'; + if (options.opts?.channel === 'sms') { + id = faker.phone.phoneNumber(); + channel = 'sms'; + } return { - id: faker.datatype.uuid(), + id: id, + channel: channel, jurisdictionId: options.jurisdiction?.id, departmentId: faker.helpers.randomize(options.departments as DepartmentAttributes[]).id, serviceRequestId: faker.helpers.randomize(options.serviceRequests as ServiceRequestAttributes[]).id, @@ -262,11 +269,14 @@ export default function makeTestData(): TestDataPayload { makeServiceRequest, 20, { staffUsers, services, jurisdiction, departments } ) as unknown as ServiceRequestAttributes[] ) - inboundMaps = inboundMaps.concat( - factory(makeInboundMap, 3, { jurisdiction, departments }) as unknown as InboundMapAttributes[] - ) + const _inboundEmailMaps = factory( + makeInboundMap, 3, { jurisdiction, departments, opts: { channel: 'email' } } + ) as unknown as InboundMapAttributes[] + const _inboundSmsMaps = factory( + makeInboundMap, 3, { jurisdiction, departments, opts: { channel: 'sms' } } + ) as unknown as InboundMapAttributes[] + inboundMaps = [..._inboundEmailMaps, ..._inboundSmsMaps]; staffUserDepartments = makeStaffUserDepartments(staffUsers, departments); - } for (const serviceRequest of serviceRequests) { diff --git a/src/types/data.ts b/src/types/data.ts index 534ef55..6bfe2de 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -111,6 +111,7 @@ export interface ParsedServiceRequestAttributes { firstName: string; lastName: string; email: string; + phone: string; description: string; createdAt: Date | undefined; departmentId?: string; @@ -176,6 +177,7 @@ export interface DepartmentInstance export interface InboundMapAttributes { id: string; + channel: string; jurisdictionId: string; departmentId?: string; staffUserId?: string; @@ -256,6 +258,37 @@ export interface TestDataMakerOptions { serviceRequests: ServiceRequestAttributes[]; departments: DepartmentAttributes[]; inboundMaps: InboundMapAttributes[]; + opts: Record; +} + +export interface InboundSmsDataAttributes { + ToCountry: string; + ToState: string; + SmsMessageSid: string; + NumMedia: string; + ToCity: string; + FromZip: string; + SmsSid: string; + FromState: string; + SmsStatus: string; + FromCity: string; + Body: string; + FromCountry: string; + To: string; + MessagingServiceSid: string; + ToZip: string; + NumSegments: string; + ReferralNumMedia: string; + MessageSid: string; + AccountSid: string; + From: string; + ApiVersion: string; +} + +export interface InboundSmsDataToRequestAttributes { + To: string; + From: string; + Body: string; } export interface InboundEmailDataAttributes { @@ -288,6 +321,8 @@ export interface InboundEmailDataToRequestAttributes { export type PublicId = string | undefined; +export type ChannelType = "email" | "sms"; + export interface EmailEventAttributes { email: string; event: string; diff --git a/src/types/repositories.ts b/src/types/repositories.ts index 6c45744..76b2dca 100644 --- a/src/types/repositories.ts +++ b/src/types/repositories.ts @@ -10,7 +10,7 @@ import type { StaffUserAttributes, StaffUserLookUpAttributes } from '.'; -import { ChannelIsAllowed, ChannelStatusAttributes, ChannelStatusInstance, EmailEventAttributes, InboundMapCreateAttributes, InboundMapInstance, ServiceRequestCommentCreateAttributes, StaffUserDepartmentAttributes } from './data'; +import { ChannelIsAllowed, ChannelStatusAttributes, ChannelStatusInstance, ChannelType, EmailEventAttributes, InboundMapCreateAttributes, InboundMapInstance, ServiceRequestCommentCreateAttributes, StaffUserDepartmentAttributes } from './data'; export interface RepositoryBase extends PluginBase { models: Models; @@ -92,7 +92,7 @@ export interface IEmailStatusRepository extends RepositoryBase { export interface IInboundMapRepository extends RepositoryBase { create: (data: InboundMapCreateAttributes) => Promise; - findOne: (id: string) => Promise; + findOne: (id: string, channel: ChannelType) => Promise; } export interface Repositories { diff --git a/src/types/services.ts b/src/types/services.ts index e82a816..7af5e38 100644 --- a/src/types/services.ts +++ b/src/types/services.ts @@ -1,5 +1,5 @@ import { AppConfig, CommunicationAttributes, JurisdictionAttributes, PluginBase, Repositories, ServiceRequestAttributes } from "."; -import { AuditedStateChangeExtraData, InboundEmailDataAttributes, InboundMapAttributes, ServiceRequestCommentAttributes, ServiceRequestStateChangeErrorResponse, StaffUserAttributes, StaffUserDepartmentAttributes, StaffUserStateChangeErrorResponse } from "./data"; +import { AuditedStateChangeExtraData, InboundEmailDataAttributes, InboundMapAttributes, InboundSmsDataAttributes, ServiceRequestCommentAttributes, ServiceRequestStateChangeErrorResponse, StaffUserAttributes, StaffUserDepartmentAttributes, StaffUserStateChangeErrorResponse } from "./data"; export interface ServiceBase extends PluginBase { repositories: Repositories; @@ -30,7 +30,7 @@ export interface IOutboundMessageService extends ServiceBase { } export interface IInboundMessageService extends ServiceBase { - createServiceRequest: (inboundEmailData: InboundEmailDataAttributes) => + createServiceRequest: (inboundData: InboundEmailDataAttributes | InboundSmsDataAttributes) => Promise<[ServiceRequestAttributes, boolean]>; createMap: (data: InboundMapAttributes) => Promise; } diff --git a/test/fixtures/inbound.ts b/test/fixtures/inbound.ts index fdb144c..7f46019 100644 --- a/test/fixtures/inbound.ts +++ b/test/fixtures/inbound.ts @@ -329,4 +329,28 @@ The actual subject line
---Apple-Mail=_5603F30B-A0D6-49C4-B110-A42BF5A2B249--` \ No newline at end of file +--Apple-Mail=_5603F30B-A0D6-49C4-B110-A42BF5A2B249--` + +export const inboundSms = { + ToCountry: 'US', + ToState: 'MI', + SmsMessageSid: 'SM0114713014e12aa16c77e2abb585c853', + NumMedia: '0', + ToCity: '', + FromZip: '', + SmsSid: 'SM0114713014e12aa16c77e2abb585c853', + FromState: '', + SmsStatus: 'received', + FromCity: '', + Body: 'This is an inbound SMS.', + FromCountry: 'US', + To: '+10111111111', + MessagingServiceSid: 'MG0bb29669c674b484c7784c08c1fbdd15', + ToZip: '', + NumSegments: '1', + ReferralNumMedia: '0', + MessageSid: 'SM0114713014e12aa16c77e2abb585c853', + AccountSid: 'ACcfb57b3ba9c7a19da1ee03f29352e291', + From: '+12111111111', + ApiVersion: '2010-04-01' +} \ No newline at end of file diff --git a/test/test-inbound.ts b/test/test-inbound-email.ts similarity index 100% rename from test/test-inbound.ts rename to test/test-inbound-email.ts diff --git a/test/test-inbound-sms.ts b/test/test-inbound-sms.ts new file mode 100644 index 0000000..025106b --- /dev/null +++ b/test/test-inbound-sms.ts @@ -0,0 +1,78 @@ +import chai from 'chai'; +import { Application } from 'express'; +import _ from 'lodash'; +import { + extractPublicIdFromInboundSms, extractServiceRequestfromInboundSms +} from '../src/core/communications/helpers'; +import { createApp } from '../src/index'; +import makeTestData, { writeTestDataToDatabase } from '../src/tools/fake-data-generator'; +import { InboundMapAttributes, TestDataPayload } from '../src/types'; +import { inboundSms } from './fixtures/inbound'; + +describe('Parse inbound sms data.', function () { + + let app: Application; + let testData: TestDataPayload; + + before(async function () { + app = await createApp(); + await app.migrator.up(); + testData = makeTestData(); + await writeTestDataToDatabase(app.database, testData); + }) + + after(async function () { + await app.database.drop({}); + }) + + it('parse a public id from an sms without a public id', async function () { + const publicId = extractPublicIdFromInboundSms(inboundSms.Body); + chai.assert.equal(publicId, null); + }); + + it('parse a public id from an sms with a public id', async function () { + const smsBody = `Request #12987: ${inboundSms.Body}` + const publicId = extractPublicIdFromInboundSms(`Request #12987: ${smsBody}`); + chai.assert.equal(publicId, '12987'); + }); + + it('parse service request data from an sms without a public id', async function () { + const { inboundMapRepository } = app.repositories; + const inboundPayload = _.cloneDeep(inboundSms); + const sample = _.find(testData.inboundMaps, map => map.channel === 'sms') as InboundMapAttributes; + inboundPayload.To = sample?.id; + const [ + { jurisdictionId, departmentId, firstName, lastName, email, phone, description }, + publicId + ] = await extractServiceRequestfromInboundSms(inboundPayload, inboundMapRepository); + chai.assert.equal(publicId, null); + chai.assert(jurisdictionId); + chai.assert(departmentId); + chai.assert.equal(phone, inboundPayload.From); + chai.assert.equal(firstName, ''); + chai.assert.equal(lastName, ''); + chai.assert.equal(email, ''); + chai.assert(description); + }); + + it('parse service request data from an sms with a public id', async function () { + const { inboundMapRepository } = app.repositories; + const inboundPayload = _.cloneDeep(inboundSms); + const sample = _.find(testData.inboundMaps, map => map.channel === 'sms') as InboundMapAttributes; + inboundPayload.To = sample?.id; + inboundPayload.Body = `Request #123456: ${inboundPayload.Body}` + const [ + { jurisdictionId, departmentId, firstName, lastName, email, phone, description }, + publicId + ] = await extractServiceRequestfromInboundSms(inboundPayload, inboundMapRepository); + chai.assert.equal(publicId, '123456'); + chai.assert(jurisdictionId); + chai.assert(departmentId); + chai.assert.equal(phone, inboundPayload.From); + chai.assert.equal(firstName, ''); + chai.assert.equal(lastName, ''); + chai.assert.equal(email, ''); + chai.assert(description); + }); + +});