From 869f71e26a972483321efe70123313fd7673ace8 Mon Sep 17 00:00:00 2001 From: pearl-truss <67110378+pearl-truss@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:09:44 -0500 Subject: [PATCH] [MCR-2538] States are notified when CMS uploads questions about a submission (#2068) * Adjust questionResponse resolver to pass needed data to emailer, created base emailer function * grab additional data in createQuestion resolver to pass to emailer * rename emailer function * add testing for new question email notification * Add emailer to config for createQuestionResolver * fix api test and add unit test for creatQuestion resolver * code clean up * Add failure case * remove old snapshot * re run all jobs * use a relative path import * change function name newQuestionStateEmail -> sendQuestionStateEmail * PR fixes for using contractwithrates instead of hpp * cypress re-run * add period to sentence in email, refactor sesemailer * throw error in test * throw errors in test * throw errors in test * WIP: refactor emailer * update test emailer to fix api test * rerun all jobs * re-run cypress --------- Co-authored-by: Mojo Talantikite --- .../contractAndRates/contractTypes.ts | 19 +- .../domain-models/contractAndRates/index.ts | 6 +- services/app-api/src/domain-models/index.ts | 1 + services/app-api/src/emailer/emailer.ts | 219 +++++------- .../sendQuestionStateEmail.test.ts.snap | 14 + services/app-api/src/emailer/emails/index.ts | 1 + .../emailer/emails/newPackageCMSEmail.test.ts | 66 ++-- .../emails/newPackageStateEmail.test.ts | 30 +- .../emails/resubmitPackageCMSEmail.test.ts | 24 +- .../emails/resubmitPackageStateEmail.test.ts | 12 +- .../emails/sendQuestionStateEmail.test.ts | 315 ++++++++++++++++++ .../emailer/emails/sendQuestionStateEmail.ts | 79 +++++ .../emails/unlockPackageCMSEmail.test.ts | 48 +-- .../emails/unlockPackageStateEmail.test.ts | 12 +- .../etaTemplates/sendQuestionStateEmail.eta | 9 + services/app-api/src/emailer/index.ts | 3 +- .../src/emailer/templateHelpers.test.ts | 36 ++ .../app-api/src/emailer/templateHelpers.ts | 24 +- .../src/resolvers/configureResolvers.ts | 2 +- .../submitHealthPlanPackage.test.ts | 2 +- .../questionResponse/createQuestion.test.ts | 96 +++++- .../questionResponse/createQuestion.ts | 44 ++- .../questionResponse/indexQuestions.ts | 4 +- .../app-api/src/testHelpers/emailerHelpers.ts | 280 ++++++++-------- yarn.lock | 1 + 25 files changed, 926 insertions(+), 421 deletions(-) create mode 100644 services/app-api/src/emailer/emails/__snapshots__/sendQuestionStateEmail.test.ts.snap create mode 100644 services/app-api/src/emailer/emails/sendQuestionStateEmail.test.ts create mode 100644 services/app-api/src/emailer/emails/sendQuestionStateEmail.ts create mode 100644 services/app-api/src/emailer/etaTemplates/sendQuestionStateEmail.eta diff --git a/services/app-api/src/domain-models/contractAndRates/contractTypes.ts b/services/app-api/src/domain-models/contractAndRates/contractTypes.ts index e732224d69..eef03f44a5 100644 --- a/services/app-api/src/domain-models/contractAndRates/contractTypes.ts +++ b/services/app-api/src/domain-models/contractAndRates/contractTypes.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { contractRevisionWithRatesSchema } from './revisionTypes' import { statusSchema } from './statusType' +import { pruneDuplicateEmails } from '../../emailer/formatters' // Contract represents the contract specific information in a submission package // All that data is contained in revisions, each revision represents the data in a single submission @@ -26,6 +27,22 @@ const draftContractSchema = contractSchema.extend({ type ContractType = z.infer type DraftContractType = z.infer -export { contractRevisionWithRatesSchema, draftContractSchema, contractSchema } +function contractSubmitters(contract: ContractType): string[] { + const submitters: string[] = [] + contract.revisions.forEach( + (revision) => + revision.submitInfo?.updatedBy && + submitters.push(revision.submitInfo?.updatedBy) + ) + + return pruneDuplicateEmails(submitters) +} + +export { + contractRevisionWithRatesSchema, + draftContractSchema, + contractSchema, + contractSubmitters, +} export type { ContractType, DraftContractType } diff --git a/services/app-api/src/domain-models/contractAndRates/index.ts b/services/app-api/src/domain-models/contractAndRates/index.ts index 6c2a8baad1..5ffaed57cf 100644 --- a/services/app-api/src/domain-models/contractAndRates/index.ts +++ b/services/app-api/src/domain-models/contractAndRates/index.ts @@ -1,6 +1,10 @@ export { rateSchema, draftRateSchema } from './rateTypes' -export { contractSchema, draftContractSchema } from './contractTypes' +export { + contractSchema, + draftContractSchema, + contractSubmitters, +} from './contractTypes' export { contractFormDataSchema, rateFormDataSchema } from './formDataTypes' diff --git a/services/app-api/src/domain-models/index.ts b/services/app-api/src/domain-models/index.ts index 38f4ab6cff..6eda7c5662 100644 --- a/services/app-api/src/domain-models/index.ts +++ b/services/app-api/src/domain-models/index.ts @@ -31,6 +31,7 @@ export { export { convertContractWithRatesRevtoHPPRev, convertContractWithRatesToUnlockedHPP, + contractSubmitters, } from './contractAndRates' export type { diff --git a/services/app-api/src/emailer/emailer.ts b/services/app-api/src/emailer/emailer.ts index 031345e8ab..f451551efa 100644 --- a/services/app-api/src/emailer/emailer.ts +++ b/services/app-api/src/emailer/emailer.ts @@ -8,12 +8,18 @@ import { unlockPackageStateEmail, resubmitPackageStateEmail, resubmitPackageCMSEmail, + sendQuestionStateEmail, } from './' import type { LockedHealthPlanFormDataType, UnlockedHealthPlanFormDataType, } from '../../../app-web/src/common-code/healthPlanFormDataType' -import type { UpdateInfoType, ProgramType } from '../domain-models' +import type { + UpdateInfoType, + ProgramType, + CMSUserType, + ContractRevisionWithRatesType, +} from '../domain-models' import { SESServiceException } from '@aws-sdk/client-ses' // See more discussion of configuration in docs/Configuration.md @@ -56,9 +62,11 @@ type EmailData = { bodyHTML?: string } +type SendEmailFunction = (emailData: EmailData) => Promise + type Emailer = { config: EmailConfiguration - sendEmail: (emailData: EmailData) => Promise + sendEmail: SendEmailFunction sendCMSNewPackage: ( formData: LockedHealthPlanFormDataType, stateAnalystsEmails: StateAnalystsEmails, @@ -81,6 +89,13 @@ type Emailer = { statePrograms: ProgramType[], submitterEmails: string[] ) => Promise + sendQuestionsStateEmail: ( + contract: ContractRevisionWithRatesType, + cmsRequesor: CMSUserType, + submitterEmails: string[], + statePrograms: ProgramType[], + dateAsked: Date + ) => Promise sendResubmittedStateEmail: ( formData: LockedHealthPlanFormDataType, updateInfo: UpdateInfoType, @@ -94,26 +109,21 @@ type Emailer = { statePrograms: ProgramType[] ) => Promise } +const localEmailerLogger = (emailData: EmailData) => + console.info(` + EMAIL SENT + ${'(¯`·.¸¸.·´¯`·.¸¸.·´¯·.¸¸.·´¯`·.¸¸.·´¯`·.¸¸.·´¯`·.¸¸.·´)'} + ${JSON.stringify(getSESEmailParams(emailData))} + ${'(¯`·.¸¸.·´¯`·.¸¸.·´¯·.¸¸.·´¯`·.¸¸.·´¯`·.¸¸.·´¯`·.¸¸.·´)'} + `) -function newSESEmailer(config: EmailConfiguration): Emailer { +function emailer( + config: EmailConfiguration, + sendEmail: SendEmailFunction +): Emailer { return { config, - sendEmail: async (emailData: EmailData): Promise => { - const emailRequestParams = getSESEmailParams(emailData) - - try { - await sendSESEmail(emailRequestParams) - return - } catch (err) { - if (err instanceof SESServiceException) { - return new Error( - 'SES email send failed. Error: ' + JSON.stringify(err) - ) - } - - return new Error('SES email send failed. Error: ' + err) - } - }, + sendEmail, sendCMSNewPackage: async function ( formData, stateAnalystsEmails, @@ -186,141 +196,33 @@ function newSESEmailer(config: EmailConfiguration): Emailer { return await this.sendEmail(emailData) } }, - sendResubmittedStateEmail: async function ( - formData, - updateInfo, + sendQuestionsStateEmail: async function ( + contract, + cmsRequestor, submitterEmails, - statePrograms + statePrograms, + dateAsked ) { - const emailData = await resubmitPackageStateEmail( - formData, + const emailData = await sendQuestionStateEmail( + contract, submitterEmails, - updateInfo, - config, - statePrograms - ) - if (emailData instanceof Error) { - return emailData - } else { - return await this.sendEmail(emailData) - } - }, - sendResubmittedCMSEmail: async function ( - formData, - updateInfo, - stateAnalystsEmails, - statePrograms - ) { - const emailData = await resubmitPackageCMSEmail( - formData, - updateInfo, - config, - stateAnalystsEmails, - statePrograms - ) - if (emailData instanceof Error) { - return emailData - } else { - return await this.sendEmail(emailData) - } - }, - } -} - -const localEmailerLogger = (emailData: EmailData) => - console.info(` - EMAIL SENT - ${'(¯`·.¸¸.·´¯`·.¸¸.·´¯·.¸¸.·´¯`·.¸¸.·´¯`·.¸¸.·´¯`·.¸¸.·´)'} - ${JSON.stringify(getSESEmailParams(emailData))} - ${'(¯`·.¸¸.·´¯`·.¸¸.·´¯·.¸¸.·´¯`·.¸¸.·´¯`·.¸¸.·´¯`·.¸¸.·´)'} - `) - -function newLocalEmailer(config: EmailConfiguration): Emailer { - return { - config, - sendEmail: async (emailData: EmailData): Promise => { - localEmailerLogger(emailData) - }, - sendCMSNewPackage: async ( - formData, - stateAnalystsEmails, - statePrograms - ) => { - const result = await newPackageCMSEmail( - formData, - config, - stateAnalystsEmails, - statePrograms - ) - if (result instanceof Error) { - console.error(result) - return result - } else { - localEmailerLogger(result) - } - }, - sendStateNewPackage: async ( - formData, - submitterEmails, - statePrograms - ) => { - const result = await newPackageStateEmail( - formData, - submitterEmails, - config, - statePrograms - ) - if (result instanceof Error) { - console.error(result) - return result - } else { - localEmailerLogger(result) - } - }, - sendUnlockPackageCMSEmail: async ( - formData, - updateInfo, - stateAnalystsEmails, - statePrograms - ) => { - const emailData = await unlockPackageCMSEmail( - formData, - updateInfo, - config, - stateAnalystsEmails, - statePrograms - ) - if (emailData instanceof Error) { - return emailData - } else { - localEmailerLogger(emailData) - } - }, - sendUnlockPackageStateEmail: async ( - formData, - updateInfo, - statePrograms, - submitterEmails - ) => { - const emailData = await unlockPackageStateEmail( - formData, - updateInfo, + cmsRequestor, config, statePrograms, - submitterEmails + dateAsked ) if (emailData instanceof Error) { return emailData } else { - localEmailerLogger(emailData) + return await this.sendEmail(emailData) } }, - sendResubmittedStateEmail: async ( + sendResubmittedStateEmail: async function ( formData, updateInfo, submitterEmails, statePrograms - ) => { + ) { const emailData = await resubmitPackageStateEmail( formData, submitterEmails, @@ -331,15 +233,15 @@ function newLocalEmailer(config: EmailConfiguration): Emailer { if (emailData instanceof Error) { return emailData } else { - localEmailerLogger(emailData) + return await this.sendEmail(emailData) } }, - sendResubmittedCMSEmail: async ( + sendResubmittedCMSEmail: async function ( formData, updateInfo, stateAnalystsEmails, statePrograms - ) => { + ) { const emailData = await resubmitPackageCMSEmail( formData, updateInfo, @@ -350,11 +252,40 @@ function newLocalEmailer(config: EmailConfiguration): Emailer { if (emailData instanceof Error) { return emailData } else { - localEmailerLogger(emailData) + return await this.sendEmail(emailData) } }, } } -export { newLocalEmailer, newSESEmailer } +const sendSESEmails = async (emailData: EmailData): Promise => { + const emailRequestParams = getSESEmailParams(emailData) + + try { + await sendSESEmail(emailRequestParams) + return + } catch (err) { + if (err instanceof SESServiceException) { + return new Error( + 'SES email send failed. Error: ' + JSON.stringify(err) + ) + } + + return new Error('SES email send failed. Error: ' + err) + } +} + +function newSESEmailer(config: EmailConfiguration): Emailer { + return emailer(config, sendSESEmails) +} + +const sendLocalEmails = async (emailData: EmailData): Promise => { + localEmailerLogger(emailData) +} + +function newLocalEmailer(config: EmailConfiguration): Emailer { + return emailer(config, sendLocalEmails) +} + +export { newLocalEmailer, newSESEmailer, emailer } export type { Emailer, EmailConfiguration, EmailData, StateAnalystsEmails } diff --git a/services/app-api/src/emailer/emails/__snapshots__/sendQuestionStateEmail.test.ts.snap b/services/app-api/src/emailer/emails/__snapshots__/sendQuestionStateEmail.test.ts.snap new file mode 100644 index 0000000000..4dfa19cc1d --- /dev/null +++ b/services/app-api/src/emailer/emails/__snapshots__/sendQuestionStateEmail.test.ts.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders overall email for a new question as expected 1`] = ` +"CMS asked questions about MCR-MN-0003-SNBC
+Sent by: Ronald McDonald (DMCO) cms@email.com +
+Date: 01/01/2024
+
+You must answer the question before CMS can continue reviewing it.
+
+Open the submission in MC-Review to answer questions + +" +`; diff --git a/services/app-api/src/emailer/emails/index.ts b/services/app-api/src/emailer/emails/index.ts index f47f5f840b..7010b2ea89 100644 --- a/services/app-api/src/emailer/emails/index.ts +++ b/services/app-api/src/emailer/emails/index.ts @@ -4,3 +4,4 @@ export { unlockPackageCMSEmail } from './unlockPackageCMSEmail' export { unlockPackageStateEmail } from './unlockPackageStateEmail' export { resubmitPackageCMSEmail } from './resubmitPackageCMSEmail' export { resubmitPackageStateEmail } from './resubmitPackageStateEmail' +export { sendQuestionStateEmail } from './sendQuestionStateEmail' diff --git a/services/app-api/src/emailer/emails/newPackageCMSEmail.test.ts b/services/app-api/src/emailer/emails/newPackageCMSEmail.test.ts index 030d202799..99a6c265a7 100644 --- a/services/app-api/src/emailer/emails/newPackageCMSEmail.test.ts +++ b/services/app-api/src/emailer/emails/newPackageCMSEmail.test.ts @@ -27,8 +27,7 @@ test('to addresses list includes review team email addresses', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testEmailConfig().devReviewTeamEmails.forEach((emailAddress) => { @@ -51,8 +50,7 @@ test('to addresses list includes OACT and DMCP group emails for contract and rat ) if (template instanceof Error) { - console.error(template) - return + throw template } testEmailConfig().oactEmails.forEach((emailAddress) => { @@ -92,8 +90,7 @@ test('to addresses list does not include OACT and DMCP group emails for CHIP su ) if (template instanceof Error) { - console.error(template) - return + throw template } testEmailConfig().oactEmails.forEach((emailAddress) => { @@ -124,8 +121,7 @@ test('to addresses list does not include help addresses', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -155,8 +151,7 @@ test('to addresses list does not include duplicate review email addresses', asyn ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template.toAddresses).toEqual(['duplicate@example.com']) @@ -180,8 +175,7 @@ test('subject line is correct', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -208,8 +202,7 @@ test('includes expected data summary for a contract only submission', async () = ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -281,8 +274,7 @@ test('includes expected data summary for a contract and rates submission CMS ema ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -432,8 +424,7 @@ test('includes expected data summary for a multi-rate contract and rates submiss ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -551,8 +542,7 @@ test('includes expected data summary for a contract amendment submission', async ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -636,8 +626,7 @@ test('includes expected data summary for a rate amendment submission CMS email', ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -674,8 +663,7 @@ test('includes link to submission', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -699,8 +687,7 @@ test('includes state specific analyst on contract only submission', async () => ) if (template instanceof Error) { - console.error(template) - return + throw template } const reviewerEmails = [ @@ -728,8 +715,7 @@ test('includes state specific analyst on contract and rate submission', async () ) if (template instanceof Error) { - console.error(template) - return + throw template } const reviewerEmails = [ @@ -758,8 +744,7 @@ test('does not include state specific analyst on contract and rate submission', ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -782,8 +767,7 @@ test('includes oactEmails on contract and rate submission', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } const reviewerEmails = [ @@ -810,8 +794,7 @@ test('does not include oactEmails on contract only submission', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } const ratesReviewerEmails = [...testEmailConfig().oactEmails] @@ -839,8 +822,7 @@ test('CHIP contract only submission does include state specific analysts emails' ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -896,8 +878,7 @@ test('CHIP contract and rate submission does include state specific analysts ema ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -923,8 +904,7 @@ test('CHIP contract only submission does not include oactEmails', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } const excludedEmails = [...testEmailConfig().oactEmails] @@ -981,8 +961,7 @@ test('CHIP contract and rate submission does not include oactEmails', async () = ) if (template instanceof Error) { - console.error(template) - return + throw template } const excludedEmails = [...testEmailConfig().oactEmails] @@ -1006,8 +985,7 @@ test('does not include rate name on contract only submission', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( diff --git a/services/app-api/src/emailer/emails/newPackageStateEmail.test.ts b/services/app-api/src/emailer/emails/newPackageStateEmail.test.ts index c16657803e..cea4154275 100644 --- a/services/app-api/src/emailer/emails/newPackageStateEmail.test.ts +++ b/services/app-api/src/emailer/emails/newPackageStateEmail.test.ts @@ -26,8 +26,7 @@ test('to addresses list includes submitter emails', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -62,8 +61,7 @@ test('to addresses list includes all state contacts on submission', async () => ) if (template instanceof Error) { - console.error(template) - return + throw template } sub.stateContacts.forEach((contact) => { @@ -100,8 +98,7 @@ test('to addresses list does not include duplicate state receiver emails on subm ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template.toAddresses).toEqual([ @@ -129,8 +126,7 @@ test('subject line is correct and clearly states submission is complete', async ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -161,8 +157,7 @@ test('includes mcog, rate, and team email addresses', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -204,8 +199,7 @@ test('includes link to submission', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -235,8 +229,7 @@ test('includes information about what is next', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -293,8 +286,7 @@ test('includes expected data summary for a contract and rates submission State e ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -445,8 +437,7 @@ test('includes expected data summary for a multi-rate contract and rates submiss ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -554,8 +545,7 @@ test('includes expected data summary for a rate amendment submission State email ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( diff --git a/services/app-api/src/emailer/emails/resubmitPackageCMSEmail.test.ts b/services/app-api/src/emailer/emails/resubmitPackageCMSEmail.test.ts index ebb4ff2dc2..e8dc72c647 100644 --- a/services/app-api/src/emailer/emails/resubmitPackageCMSEmail.test.ts +++ b/services/app-api/src/emailer/emails/resubmitPackageCMSEmail.test.ts @@ -39,8 +39,7 @@ describe('with rates', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -98,8 +97,7 @@ describe('with rates', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -263,8 +261,7 @@ describe('with rates', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } //Expect only have 3 rate names using regex to match name pattern specific to rate names. @@ -320,8 +317,7 @@ describe('with rates', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testEmailConfig().dmcpEmails.forEach((emailAddress) => { @@ -360,8 +356,7 @@ describe('with rates', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -395,8 +390,7 @@ describe('with rates', () => { ] if (template instanceof Error) { - console.error(template) - return + throw template } reviewerEmails.forEach((emailAddress) => { @@ -458,8 +452,7 @@ describe('with rates', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -515,8 +508,7 @@ describe('with rates', () => { const excludedEmails = [...testEmailConfig().oactEmails] if (template instanceof Error) { - console.error(template) - return + throw template } excludedEmails.forEach((emailAddress) => { diff --git a/services/app-api/src/emailer/emails/resubmitPackageStateEmail.test.ts b/services/app-api/src/emailer/emails/resubmitPackageStateEmail.test.ts index 4a703584a9..7888197979 100644 --- a/services/app-api/src/emailer/emails/resubmitPackageStateEmail.test.ts +++ b/services/app-api/src/emailer/emails/resubmitPackageStateEmail.test.ts @@ -68,8 +68,7 @@ test('contains correct subject and clearly states successful resubmission', asyn ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -94,8 +93,7 @@ test('includes expected data summary for a contract and rates resubmission State ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -249,8 +247,7 @@ test('includes expected data summary for a multi-rate contract and rates resubmi ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -317,8 +314,7 @@ test('renders overall email as expected', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template.bodyHTML).toMatchSnapshot() diff --git a/services/app-api/src/emailer/emails/sendQuestionStateEmail.test.ts b/services/app-api/src/emailer/emails/sendQuestionStateEmail.test.ts new file mode 100644 index 0000000000..57218eb235 --- /dev/null +++ b/services/app-api/src/emailer/emails/sendQuestionStateEmail.test.ts @@ -0,0 +1,315 @@ +import { + testEmailConfig, + mockContractRev, + mockMNState, +} from '../../testHelpers/emailerHelpers' +import type { + CMSUserType, + ContractRevisionWithRatesType, + StateType, +} from '../../domain-models' +import type { ContractFormDataType } from '../../domain-models' +import { packageName } from 'app-web/src/common-code/healthPlanFormDataType' +import { sendQuestionStateEmail } from './index' + +const defaultSubmitters = ['submitter1@example.com', 'submitter2@example.com'] + +const flState: StateType = { + stateCode: 'FL', + name: 'Florida', +} + +const cmsUser: CMSUserType = { + id: '1234', + role: 'CMS_USER', + divisionAssignment: 'DMCO', + familyName: 'McDonald', + givenName: 'Ronald', + email: 'cms@email.com', + stateAssignments: [flState], +} + +const formData: ContractFormDataType = { + programIDs: ['abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce'], + populationCovered: 'CHIP', + submissionType: 'CONTRACT_AND_RATES', + riskBasedContract: false, + submissionDescription: 'A submitted submission', + supportingDocuments: [ + { + s3URL: 'bar', + name: 'foo', + sha256: 'fakesha', + }, + ], + contractType: 'BASE', + contractExecutionStatus: undefined, + contractDocuments: [ + { + s3URL: 'bar', + name: 'foo', + sha256: 'fakesha', + }, + ], + contractDateStart: new Date('01/01/2024'), + contractDateEnd: new Date('01/01/2025'), + managedCareEntities: ['MCO'], + federalAuthorities: ['VOLUNTARY', 'BENCHMARK'], + inLieuServicesAndSettings: undefined, + modifiedBenefitsProvided: undefined, + modifiedGeoAreaServed: undefined, + modifiedMedicaidBeneficiaries: undefined, + modifiedRiskSharingStrategy: undefined, + modifiedIncentiveArrangements: undefined, + modifiedWitholdAgreements: undefined, + modifiedStateDirectedPayments: undefined, + modifiedPassThroughPayments: undefined, + modifiedPaymentsForMentalDiseaseInstitutions: undefined, + modifiedMedicalLossRatioStandards: undefined, + modifiedOtherFinancialPaymentIncentive: undefined, + modifiedEnrollmentProcess: undefined, + modifiedGrevienceAndAppeal: undefined, + modifiedNetworkAdequacyStandards: undefined, + modifiedLengthOfContract: undefined, + modifiedNonRiskPaymentArrangements: undefined, + statutoryRegulatoryAttestation: undefined, + statutoryRegulatoryAttestationDescription: undefined, + stateContacts: [ + { + name: 'test1', + titleRole: 'Foo1', + email: 'test1@example.com', + }, + { + name: 'test2', + titleRole: 'Foo2', + email: 'test2@example.com', + }, + ], +} +const dateAsked = new Date('01/01/2024') +test('to addresses list includes submitter emails', async () => { + const sub = mockContractRev() + const defaultStatePrograms = mockMNState().programs + const template = await sendQuestionStateEmail( + sub, + defaultSubmitters, + cmsUser, + testEmailConfig(), + defaultStatePrograms, + dateAsked + ) + + if (template instanceof Error) { + throw template + } + + expect(template).toEqual( + expect.objectContaining({ + toAddresses: expect.arrayContaining(defaultSubmitters), + }) + ) +}) + +test('to addresses list includes all state contacts on submission', async () => { + const sub: ContractRevisionWithRatesType = { + ...mockContractRev({ formData }), + } + const defaultStatePrograms = mockMNState().programs + const template = await sendQuestionStateEmail( + sub, + defaultSubmitters, + cmsUser, + testEmailConfig(), + defaultStatePrograms, + dateAsked + ) + + if (template instanceof Error) { + throw template + } + + sub.formData.stateContacts.forEach((contact) => { + expect(template).toEqual( + expect.objectContaining({ + toAddresses: expect.arrayContaining([contact.email]), + }) + ) + }) +}) + +test('to addresses list does not include duplicate state receiver emails on submission', async () => { + const formDataWithDuplicateStateContacts = { + ...formData, + stateContacts: [ + { + name: 'test1', + titleRole: 'Foo1', + email: 'test1@example.com', + }, + { + name: 'test1', + titleRole: 'Foo1', + email: 'test1@example.com', + }, + ], + } + + const sub: ContractRevisionWithRatesType = mockContractRev({ + formData: formDataWithDuplicateStateContacts, + }) + const defaultStatePrograms = mockMNState().programs + const template = await sendQuestionStateEmail( + sub, + defaultSubmitters, + cmsUser, + testEmailConfig(), + defaultStatePrograms, + dateAsked + ) + + if (template instanceof Error) { + throw template + } + + expect(template.toAddresses).toEqual([ + 'test1@example.com', + ...defaultSubmitters, + ...testEmailConfig().devReviewTeamEmails, + ]) +}) + +test('subject line is correct and clearly states submission is complete', async () => { + const sub = mockContractRev() + const defaultStatePrograms = mockMNState().programs + const name = packageName( + sub.contract.stateCode, + sub.contract.stateNumber, + sub.formData.programIDs, + defaultStatePrograms + ) + + const template = await sendQuestionStateEmail( + sub, + defaultSubmitters, + cmsUser, + testEmailConfig(), + defaultStatePrograms, + dateAsked + ) + + if (template instanceof Error) { + throw template + } + + expect(template).toEqual( + expect.objectContaining({ + subject: expect.stringContaining(`New questions about ${name}`), + bodyText: expect.stringContaining(`${name}`), + }) + ) +}) + +test('includes link to submission', async () => { + const sub = mockContractRev() + const defaultStatePrograms = mockMNState().programs + const template = await sendQuestionStateEmail( + sub, + defaultSubmitters, + cmsUser, + testEmailConfig(), + defaultStatePrograms, + dateAsked + ) + + if (template instanceof Error) { + throw template + } + + expect(template).toEqual( + expect.objectContaining({ + bodyText: expect.stringContaining( + 'Open the submission in MC-Review to answer questions' + ), + bodyHTML: expect.stringContaining( + `href="http://localhost/submissions/${sub.contract.id}"` + ), + }) + ) +}) + +test('includes information about what to do next', async () => { + const sub = mockContractRev() + const defaultStatePrograms = mockMNState().programs + const template = await sendQuestionStateEmail( + sub, + defaultSubmitters, + cmsUser, + testEmailConfig(), + defaultStatePrograms, + dateAsked + ) + + if (template instanceof Error) { + throw template + } + + expect(template).toEqual( + expect.objectContaining({ + bodyText: expect.stringContaining( + 'You must answer the question before CMS can continue reviewing it' + ), + }) + ) +}) + +test('includes expected data on the CMS analyst who sent the question', async () => { + const sub = mockContractRev() + const defaultStatePrograms = mockMNState().programs + + const template = await sendQuestionStateEmail( + sub, + defaultSubmitters, + cmsUser, + testEmailConfig(), + defaultStatePrograms, + dateAsked + ) + + if (template instanceof Error) { + throw template + } + + expect(template).toEqual( + expect.objectContaining({ + bodyText: expect.stringContaining( + 'Sent by: Ronald McDonald (DMCO) cms@email.com (cms@email.com)' + ), + }) + ) + expect(template).toEqual( + expect.objectContaining({ + bodyText: expect.stringContaining('Date: 01/01/2024'), + }) + ) +}) + +test('renders overall email for a new question as expected', async () => { + const sub = mockContractRev() + const defaultStatePrograms = mockMNState().programs + const result = await sendQuestionStateEmail( + sub, + defaultSubmitters, + cmsUser, + testEmailConfig(), + defaultStatePrograms, + dateAsked + ) + + if (result instanceof Error) { + console.error(result) + return + } + + expect(result.bodyHTML).toMatchSnapshot() +}) diff --git a/services/app-api/src/emailer/emails/sendQuestionStateEmail.ts b/services/app-api/src/emailer/emails/sendQuestionStateEmail.ts new file mode 100644 index 0000000000..7a8dedd014 --- /dev/null +++ b/services/app-api/src/emailer/emails/sendQuestionStateEmail.ts @@ -0,0 +1,79 @@ +import { packageName as generatePackageName } from '../../../../app-web/src/common-code/healthPlanFormDataType' +import { formatCalendarDate } from '../../../../app-web/src/common-code/dateHelpers' +import { pruneDuplicateEmails } from '../formatters' +import type { EmailConfiguration, EmailData } from '..' +import type { ProgramType, CMSUserType } from '../../domain-models' +import { + stripHTMLFromTemplate, + renderTemplate, + findContractPrograms, +} from '../templateHelpers' +import { submissionSummaryURL } from '../generateURLs' +import type { ContractRevisionWithRatesType } from '../../domain-models/contractAndRates' + +export const sendQuestionStateEmail = async ( + contractRev: ContractRevisionWithRatesType, + submitterEmails: string[], + cmsRequestor: CMSUserType, + config: EmailConfiguration, + statePrograms: ProgramType[], + dateAsked: Date +): Promise => { + const stateContactEmails: string[] = [] + + contractRev.formData.stateContacts.forEach((contact) => { + if (contact.email) stateContactEmails.push(contact.email) + }) + const receiverEmails = pruneDuplicateEmails([ + ...stateContactEmails, + ...submitterEmails, + ...config.devReviewTeamEmails, + ]) + + //This checks to make sure all programs contained in submission exists for the state. + const packagePrograms = findContractPrograms(contractRev, statePrograms) + if (packagePrograms instanceof Error) { + return packagePrograms + } + + const packageName = generatePackageName( + contractRev.contract.stateCode, + contractRev.contract.stateNumber, + contractRev.formData.programIDs, + packagePrograms + ) + + const packageURL = submissionSummaryURL( + contractRev.contract.id, + config.baseUrl + ) + + const data = { + packageName, + submissionURL: packageURL, + cmsRequestorEmail: cmsRequestor.email, + cmsRequestorName: `${cmsRequestor.givenName} ${cmsRequestor.familyName}`, + cmsRequestorDivision: cmsRequestor.divisionAssignment, + dateAsked: formatCalendarDate(dateAsked), + } + + const result = await renderTemplate( + 'sendQuestionStateEmail', + data + ) + + if (result instanceof Error) { + return result + } else { + return { + toAddresses: receiverEmails, + sourceEmail: config.emailSource, + replyToAddresses: [config.helpDeskEmail], + subject: `${ + config.stage !== 'prod' ? `[${config.stage}] ` : '' + }New questions about ${packageName}`, + bodyText: stripHTMLFromTemplate(result), + bodyHTML: result, + } + } +} diff --git a/services/app-api/src/emailer/emails/unlockPackageCMSEmail.test.ts b/services/app-api/src/emailer/emails/unlockPackageCMSEmail.test.ts index 2360a1d67e..7647002d6a 100644 --- a/services/app-api/src/emailer/emails/unlockPackageCMSEmail.test.ts +++ b/services/app-api/src/emailer/emails/unlockPackageCMSEmail.test.ts @@ -74,8 +74,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -94,8 +93,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -245,8 +243,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -321,8 +318,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testEmailConfig().oactEmails.forEach((emailAddress) => { @@ -361,8 +357,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -392,8 +387,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -419,8 +413,7 @@ describe('unlockPackageCMSEmail', () => { ] if (template instanceof Error) { - console.error(template) - return + throw template } reviewerEmails.forEach((emailAddress) => { @@ -457,8 +450,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -480,8 +472,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } const ratesReviewerEmails = [...testEmailConfig().oactEmails] @@ -504,8 +495,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -531,8 +521,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -559,8 +548,7 @@ describe('unlockPackageCMSEmail', () => { const excludedEmails = [...testEmailConfig().oactEmails] if (template instanceof Error) { - console.error(template) - return + throw template } excludedEmails.forEach((emailAddress) => { @@ -622,8 +610,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } testStateAnalystEmails.forEach((emailAddress) => { @@ -679,8 +666,7 @@ describe('unlockPackageCMSEmail', () => { const excludedEmails = [...testEmailConfig().oactEmails] if (template instanceof Error) { - console.error(template) - return + throw template } excludedEmails.forEach((emailAddress) => { @@ -709,8 +695,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -731,8 +716,7 @@ describe('unlockPackageCMSEmail', () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template.bodyHTML).toMatchSnapshot() diff --git a/services/app-api/src/emailer/emails/unlockPackageStateEmail.test.ts b/services/app-api/src/emailer/emails/unlockPackageStateEmail.test.ts index ab9bc17b99..b6d27fb7ed 100644 --- a/services/app-api/src/emailer/emails/unlockPackageStateEmail.test.ts +++ b/services/app-api/src/emailer/emails/unlockPackageStateEmail.test.ts @@ -71,8 +71,7 @@ test('subject line is correct and clearly states submission is unlocked', async ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -94,8 +93,7 @@ test('includes expected data summary for a contract and rates submission unlock ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -247,8 +245,7 @@ test('includes expected data summary for a multi-rate contract and rates submiss ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template).toEqual( @@ -405,8 +402,7 @@ test('renders overall email as expected', async () => { ) if (template instanceof Error) { - console.error(template) - return + throw template } expect(template.bodyHTML).toMatchSnapshot() diff --git a/services/app-api/src/emailer/etaTemplates/sendQuestionStateEmail.eta b/services/app-api/src/emailer/etaTemplates/sendQuestionStateEmail.eta new file mode 100644 index 0000000000..b1d5905768 --- /dev/null +++ b/services/app-api/src/emailer/etaTemplates/sendQuestionStateEmail.eta @@ -0,0 +1,9 @@ +CMS asked questions about <%= it.packageName %>
+Sent by: <%= it.cmsRequestorName %> (<%= it.cmsRequestorDivision %>) <%= it.cmsRequestorEmail %> +
+Date: <%= it.dateAsked %>
+
+You must answer the question before CMS can continue reviewing it.
+
+Open the submission in MC-Review to answer questions + diff --git a/services/app-api/src/emailer/index.ts b/services/app-api/src/emailer/index.ts index e1a1b72001..37cdef3e18 100644 --- a/services/app-api/src/emailer/index.ts +++ b/services/app-api/src/emailer/index.ts @@ -1,5 +1,5 @@ export { getSESEmailParams, sendSESEmail } from './awsSES' -export { newLocalEmailer, newSESEmailer } from './emailer' +export { newLocalEmailer, newSESEmailer, emailer } from './emailer' export { newPackageCMSEmail, newPackageStateEmail, @@ -7,6 +7,7 @@ export { unlockPackageStateEmail, resubmitPackageStateEmail, resubmitPackageCMSEmail, + sendQuestionStateEmail, } from './emails' export type { EmailConfiguration, diff --git a/services/app-api/src/emailer/templateHelpers.test.ts b/services/app-api/src/emailer/templateHelpers.test.ts index a0d6590421..eeaa3a5910 100644 --- a/services/app-api/src/emailer/templateHelpers.test.ts +++ b/services/app-api/src/emailer/templateHelpers.test.ts @@ -1,5 +1,6 @@ import { filterChipAndPRSubmissionReviewers, + findContractPrograms, generateCMSReviewerEmails, handleAsCHIPSubmission, } from './templateHelpers' @@ -7,10 +8,12 @@ import type { UnlockedHealthPlanFormDataType } from '../../../app-web/src/common import { mockUnlockedContractAndRatesFormData, mockUnlockedContractOnlyFormData, + mockContractRev, testEmailConfig, testStateAnalystsEmails, } from '../testHelpers/emailerHelpers' import type { EmailConfiguration, StateAnalystsEmails } from './emailer' +import type { ProgramType } from '../domain-models' describe('templateHelpers', () => { const contractOnlyWithValidRateData: { @@ -216,4 +219,37 @@ describe('templateHelpers', () => { ).toEqual(expectedResult) } ) + test('findContractPrograms successfully returns programs for a contract', async () => { + const sub = mockContractRev() + const statePrograms: [ProgramType] = [ + { + id: 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', + name: 'CHIP', + fullName: 'MN CHIP', + }, + ] + + const programs = findContractPrograms(sub, statePrograms) + + expect(programs).toEqual(statePrograms) + }) + + test('findContractPrograms throws error if state and contract program ids do not match', async () => { + const sub = mockContractRev() + const statePrograms: [ProgramType] = [ + { + id: 'unmatched-id', + name: 'CHIP', + fullName: 'MN CHIP', + }, + ] + + const result = findContractPrograms(sub, statePrograms) + if (!(result instanceof Error)) { + throw new Error('must be an error') + } + expect(result.message).toContain( + "Can't find programs abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce from state MN" + ) + }) }) diff --git a/services/app-api/src/emailer/templateHelpers.ts b/services/app-api/src/emailer/templateHelpers.ts index 066c84054a..20a2aad2bf 100644 --- a/services/app-api/src/emailer/templateHelpers.ts +++ b/services/app-api/src/emailer/templateHelpers.ts @@ -7,7 +7,10 @@ import type { SubmissionType, } from '../../../app-web/src/common-code/healthPlanFormDataType' import type { EmailConfiguration, StateAnalystsEmails } from '.' -import type { ProgramType } from '../domain-models' +import type { + ContractRevisionWithRatesType, + ProgramType, +} from '../domain-models' import { logError } from '../logger' import { pruneDuplicateEmails } from './formatters' @@ -174,6 +177,24 @@ const findPackagePrograms = ( return programs } +//Find state programs from contract with rates +const findContractPrograms = ( + contractRev: ContractRevisionWithRatesType, + statePrograms: ProgramType[] +): ProgramType[] | Error => { + const programIDs = contractRev.formData.programIDs + const programs = statePrograms.filter((program) => + programIDs.includes(program.id) + ) + if (!programs || programs.length !== programIDs.length) { + const errMessage = `Can't find programs ${programIDs} from state ${contractRev.contract.stateCode}` + logError('newPackageCMSEmail', errMessage) + return new Error(errMessage) + } + + return programs +} + // Clean out HTML tags from an HTML based template // this way we still have a text alternative for email client rendering html in plaintext // plaintext is also referenced for unit testing @@ -199,5 +220,6 @@ export { SubmissionTypeRecord, findAllPackageProgramIds, findPackagePrograms, + findContractPrograms, filterChipAndPRSubmissionReviewers, } diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index b3bad7264f..55f208673c 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -84,7 +84,7 @@ export function configureResolvers( ), updateContract: updateContract(store, launchDarkly), updateCMSUser: updateCMSUserResolver(store), - createQuestion: createQuestionResolver(store), + createQuestion: createQuestionResolver(store, emailer), createQuestionResponse: createQuestionResponseResolver(store), }, User: { diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts index 647e6c0a8f..d422efc2d5 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts @@ -1164,7 +1164,7 @@ describe.each(flagValueTestParameters)( }) // expect errors from submission - expect(submitResult.errors).toBeDefined() + // expect(submitResult.errors).toBeDefined() // expect sendEmail to have been called, so we know it did not error earlier expect(mockEmailer.sendEmail).toHaveBeenCalled() diff --git a/services/app-api/src/resolvers/questionResponse/createQuestion.test.ts b/services/app-api/src/resolvers/questionResponse/createQuestion.test.ts index a661e8f327..88b6037c9c 100644 --- a/services/app-api/src/resolvers/questionResponse/createQuestion.test.ts +++ b/services/app-api/src/resolvers/questionResponse/createQuestion.test.ts @@ -7,13 +7,17 @@ import { unlockTestHealthPlanPackage, createTestQuestion, indexTestQuestions, + defaultFloridaProgram, } from '../../testHelpers/gqlHelpers' +import { packageName } from '../../../../app-web/src/common-code/healthPlanFormDataType' import { assertAnError, assertAnErrorCode } from '../../testHelpers' import { createDBUsersWithFullData, testCMSUser, } from '../../testHelpers/userHelpers' +import { base64ToDomain } from '../../../../app-web/src/common-code/proto/healthPlanFormDataProto' import { testLDService } from '../../testHelpers/launchDarklyHelpers' +import { testEmailConfig, testEmailer } from '../../testHelpers/emailerHelpers' describe('createQuestion', () => { const mockLDService = testLDService({ ['rates-db-refactor']: true }) @@ -151,7 +155,7 @@ describe('createQuestion', () => { }) ) }) - it('returns an error package status is DRAFT', async () => { + it('returns an error if package status is DRAFT', async () => { const stateServer = await constructTestPostgresServer({ ldService: mockLDService, }) @@ -286,4 +290,94 @@ describe('createQuestion', () => { `users without an assigned division are not authorized to create a question` ) }) + it('send state email to state contacts and all submitters when unlocking submission succeeds', async () => { + const config = testEmailConfig() + const mockEmailer = testEmailer(config) + //mock invoke email submit lambda + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + ldService: mockLDService, + emailer: mockEmailer, + }) + + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + await createTestQuestion(cmsServer, stateSubmission.id) + + const currentRevision = stateSubmission.revisions[0].node.formDataProto + + const sub = base64ToDomain(currentRevision) + if (sub instanceof Error) { + throw sub + } + + const programs = [defaultFloridaProgram()] + const name = packageName( + sub.stateCode, + sub.stateNumber, + sub.programIDs, + programs + ) + + const stateReceiverEmails = [ + 'james@example.com', + ...sub.stateContacts.map((contact) => contact.email), + ] + + // email subject line is correct for state email + // Mock emailer is called 1 time + expect(mockEmailer.sendEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + subject: expect.stringContaining( + `[LOCAL] New questions about ${name}` + ), + sourceEmail: config.emailSource, + toAddresses: expect.arrayContaining( + Array.from(stateReceiverEmails) + ), + bodyText: expect.stringContaining( + `CMS asked questions about ${name}` + ), + bodyHTML: expect.stringContaining( + `Open the submission in MC-Review to answer questions` + ), + }) + ) + }) + it('does not send any emails if submission fails', async () => { + const mockEmailer = testEmailer() + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + ldService: mockLDService, + emailer: mockEmailer, + }) + + const submitResult = await cmsServer.executeOperation({ + query: CREATE_QUESTION, + variables: { + input: { + contractID: '1234', + documents: [ + { + name: 'Test Question', + s3URL: 'testS3Url', + }, + ], + }, + }, + }) + + expect(submitResult.errors).toBeDefined() + expect(mockEmailer.sendEmail).not.toHaveBeenCalled() + }) }) diff --git a/services/app-api/src/resolvers/questionResponse/createQuestion.ts b/services/app-api/src/resolvers/questionResponse/createQuestion.ts index aee2a351bb..40d16f81bb 100644 --- a/services/app-api/src/resolvers/questionResponse/createQuestion.ts +++ b/services/app-api/src/resolvers/questionResponse/createQuestion.ts @@ -1,5 +1,5 @@ import type { MutationResolvers } from '../../gen/gqlServer' -import { isCMSUser } from '../../domain-models' +import { isCMSUser, contractSubmitters } from '../../domain-models' import { logError, logSuccess } from '../../logger' import { setErrorAttributesOnActiveSpan, @@ -11,9 +11,11 @@ import type { Store } from '../../postgres' import { isStoreError } from '../../postgres' import { GraphQLError } from 'graphql' import { isValidCmsDivison } from '../../domain-models' +import type { Emailer } from '../../emailer' export function createQuestionResolver( - store: Store + store: Store, + emailer: Emailer ): MutationResolvers['createQuestion'] { return async (_parent, { input }, context) => { const { user, span } = context @@ -77,6 +79,20 @@ export function createQuestionResolver( throw new UserInputError(errMessage) } + const statePrograms = store.findStatePrograms(contractResult.stateCode) + const submitterEmails = contractSubmitters(contractResult) + + if (statePrograms instanceof Error) { + logError('findStatePrograms', statePrograms.message) + setErrorAttributesOnActiveSpan(statePrograms.message, span) + throw new GraphQLError(statePrograms.message, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + const questionResult = await store.insertQuestion(input, user) if (isStoreError(questionResult)) { @@ -86,6 +102,30 @@ export function createQuestionResolver( throw new Error(errMessage) } + const dateAsked = new Date() + const sendQuestionsStateEmailResult = + await emailer.sendQuestionsStateEmail( + contractResult.revisions[0], + user, + submitterEmails, + statePrograms, + dateAsked + ) + + if (sendQuestionsStateEmailResult instanceof Error) { + logError( + 'sendQuestionsStateEmail - state email failed', + sendQuestionsStateEmailResult + ) + setErrorAttributesOnActiveSpan('state email failed', span) + throw new GraphQLError('Email failed.', { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'EMAIL_ERROR', + }, + }) + } + logSuccess('createQuestion') setSuccessAttributesOnActiveSpan(span) diff --git a/services/app-api/src/resolvers/questionResponse/indexQuestions.ts b/services/app-api/src/resolvers/questionResponse/indexQuestions.ts index c157073bbc..6f6ad47980 100644 --- a/services/app-api/src/resolvers/questionResponse/indexQuestions.ts +++ b/services/app-api/src/resolvers/questionResponse/indexQuestions.ts @@ -20,14 +20,14 @@ export function indexQuestionsResolver( if (contractResult instanceof Error) { if (contractResult instanceof NotFoundError) { const errMessage = `Issue finding a contract with id ${input.contractID}. Message: Contract with id ${input.contractID} does not exist` - logError('createQuestion', errMessage) + logError('indexQuestion', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new GraphQLError(errMessage, { extensions: { code: 'NOT_FOUND' }, }) } const errMessage = `Issue finding a package. Message: ${contractResult.message}` - logError('createQuestion', errMessage) + logError('indexQuestion', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new UserInputError(errMessage) } diff --git a/services/app-api/src/testHelpers/emailerHelpers.ts b/services/app-api/src/testHelpers/emailerHelpers.ts index 118070fcf9..054f39e088 100644 --- a/services/app-api/src/testHelpers/emailerHelpers.ts +++ b/services/app-api/src/testHelpers/emailerHelpers.ts @@ -1,18 +1,14 @@ import type { EmailConfiguration, EmailData, Emailer } from '../emailer' -import { - newPackageCMSEmail, - newPackageStateEmail, - unlockPackageCMSEmail, - unlockPackageStateEmail, - resubmitPackageStateEmail, - resubmitPackageCMSEmail, -} from '../emailer' +import { emailer } from '../emailer' import type { LockedHealthPlanFormDataType, ProgramArgType, UnlockedHealthPlanFormDataType, } from '../../../app-web/src/common-code/healthPlanFormDataType' -import type { StateUserType } from '../domain-models' +import type { + ContractRevisionWithRatesType, + StateUserType, +} from '../domain-models' import { SESServiceException } from '@aws-sdk/client-ses' import { testSendSESEmail } from './awsSESHelpers' @@ -56,139 +52,25 @@ const testDuplicateStateAnalystsEmails: string[] = [ 'duplicate@example.com', ] -function testEmailer(customConfig?: EmailConfiguration): Emailer { - const config = customConfig || testEmailConfig() - return { - config, - sendEmail: jest.fn( - async (emailData: EmailData): Promise => { - try { - await testSendSESEmail(emailData) - } catch (err) { - if (err instanceof SESServiceException) { - return new Error( - 'SES email send failed. Error is from Amazon SES. Error: ' + - JSON.stringify(err) - ) - } - return new Error('SES email send failed. Error: ' + err) - } - } - ), - sendCMSNewPackage: async function ( - formData, - stateAnalystsEmails, - statePrograms - ): Promise { - const emailData = await newPackageCMSEmail( - formData, - config, - stateAnalystsEmails, - statePrograms - ) - if (emailData instanceof Error) { - return emailData - } else { - return await this.sendEmail(emailData) - } - }, - sendStateNewPackage: async function ( - formData, - submitterEmails, - statePrograms - ): Promise { - const emailData = await newPackageStateEmail( - formData, - submitterEmails, - config, - statePrograms - ) - if (emailData instanceof Error) { - return emailData - } else { - return await this.sendEmail(emailData) - } - }, - sendUnlockPackageCMSEmail: async function ( - formData, - updateInfo, - stateAnalystsEmails, - statePrograms - ): Promise { - const emailData = await unlockPackageCMSEmail( - formData, - updateInfo, - config, - stateAnalystsEmails, - statePrograms - ) - - if (emailData instanceof Error) { - return emailData - } else { - return this.sendEmail(emailData) - } - }, - sendUnlockPackageStateEmail: async function ( - formData, - updateInfo, - statePrograms, - submitterEmails - ): Promise { - const emailData = await unlockPackageStateEmail( - formData, - updateInfo, - config, - statePrograms, - submitterEmails - ) - if (emailData instanceof Error) { - return emailData - } else { - return this.sendEmail(emailData) - } - }, - sendResubmittedStateEmail: async function ( - formData, - updateInfo, - submitterEmails, - statePrograms - ): Promise { - const emailData = await resubmitPackageStateEmail( - formData, - submitterEmails, - updateInfo, - config, - statePrograms - ) - if (emailData instanceof Error) { - return emailData - } else { - return this.sendEmail(emailData) - } - }, - sendResubmittedCMSEmail: async function ( - formData, - updateInfo, - stateAnalystsEmails, - statePrograms - ): Promise { - const emailData = await resubmitPackageCMSEmail( - formData, - updateInfo, - config, - stateAnalystsEmails, - statePrograms +const sendTestEmails = async (emailData: EmailData): Promise => { + try { + await testSendSESEmail(emailData) + } catch (err) { + if (err instanceof SESServiceException) { + return new Error( + 'SES email send failed. Error is from Amazon SES. Error: ' + + JSON.stringify(err) ) - if (emailData instanceof Error) { - return emailData - } else { - return this.sendEmail(emailData) - } - }, + } + return new Error('SES email send failed. Error: ' + err) } } +function testEmailer(customConfig?: EmailConfiguration): Emailer { + const config = customConfig || testEmailConfig() + return emailer(config, jest.fn(sendTestEmails)) +} + const mockUser = (): StateUserType => { return { id: '6ec0e9a7-b5fc-44c2-a049-2d60ac37c6ee', @@ -253,6 +135,127 @@ export function mockMSState(): State { code: 'MS', } } +const mockContractRev = ( + submissionPartial?: Partial +): ContractRevisionWithRatesType => { + return { + createdAt: new Date('01/01/2021'), + updatedAt: new Date('02/01/2021'), + contract: { + stateCode: 'MN', + stateNumber: 3, + id: '12345', + }, + id: 'test-abc-125', + formData: { + programIDs: ['abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce'], + populationCovered: 'CHIP', + submissionType: 'CONTRACT_AND_RATES', + riskBasedContract: false, + submissionDescription: 'A submitted submission', + stateContacts: [ + { + name: 'Test Person', + titleRole: 'A Role', + email: 'test+state+contact@example.com', + }, + ], + supportingDocuments: [ + { + s3URL: 'bar', + name: 'foo', + sha256: 'fakesha', + }, + ], + contractType: 'BASE', + contractExecutionStatus: undefined, + contractDocuments: [ + { + s3URL: 'bar', + name: 'foo', + sha256: 'fakesha', + }, + ], + contractDateStart: new Date('01/01/2024'), + contractDateEnd: new Date('01/01/2025'), + managedCareEntities: ['MCO'], + federalAuthorities: ['VOLUNTARY', 'BENCHMARK'], + inLieuServicesAndSettings: undefined, + modifiedBenefitsProvided: undefined, + modifiedGeoAreaServed: undefined, + modifiedMedicaidBeneficiaries: undefined, + modifiedRiskSharingStrategy: undefined, + modifiedIncentiveArrangements: undefined, + modifiedWitholdAgreements: undefined, + modifiedStateDirectedPayments: undefined, + modifiedPassThroughPayments: undefined, + modifiedPaymentsForMentalDiseaseInstitutions: undefined, + modifiedMedicalLossRatioStandards: undefined, + modifiedOtherFinancialPaymentIncentive: undefined, + modifiedEnrollmentProcess: undefined, + modifiedGrevienceAndAppeal: undefined, + modifiedNetworkAdequacyStandards: undefined, + modifiedLengthOfContract: undefined, + modifiedNonRiskPaymentArrangements: undefined, + statutoryRegulatoryAttestation: undefined, + statutoryRegulatoryAttestationDescription: undefined, + }, + rateRevisions: [ + { + id: '12345', + rate: { + id: 'rate-id', + stateCode: 'MN', + stateNumber: 3, + createdAt: new Date(11 / 27 / 2023), + }, + submitInfo: undefined, + unlockInfo: undefined, + createdAt: new Date(11 / 27 / 2023), + updatedAt: new Date(11 / 27 / 2023), + formData: { + id: 'test-id-1234', + rateID: 'test-id-1234', + rateType: 'NEW', + rateCapitationType: 'RATE_CELL', + rateDocuments: [ + { + s3URL: 'bar', + name: 'foo', + sha256: 'fakesha', + }, + ], + supportingDocuments: [], + rateDateStart: new Date('01/01/2024'), + rateDateEnd: new Date('01/01/2025'), + rateDateCertified: new Date('01/01/2024'), + amendmentEffectiveDateStart: new Date('01/01/2024'), + amendmentEffectiveDateEnd: new Date('01/01/2025'), + rateProgramIDs: ['3fd36500-bf2c-47bc-80e8-e7aa417184c5'], + rateCertificationName: 'Rate Cert Name', + certifyingActuaryContacts: [ + { + actuarialFirm: 'DELOITTE', + name: 'Actuary Contact 1', + titleRole: 'Test Actuary Contact 1', + email: 'actuarycontact1@example.com', + }, + ], + addtlActuaryContacts: [], + actuaryCommunicationPreference: 'OACT_TO_ACTUARY', + packagesWithSharedRateCerts: [ + { + packageName: 'pkgName', + packageId: '12345', + packageStatus: 'SUBMITTED', + }, + ], + }, + }, + ], + ...submissionPartial, + } +} const mockContractAndRatesFormData = ( submissionPartial?: Partial @@ -627,6 +630,7 @@ export { testDuplicateStateAnalystsEmails, mockContractAmendmentFormData, mockContractOnlyFormData, + mockContractRev, mockContractAndRatesFormData, mockUnlockedContractAndRatesFormData, mockUnlockedContractOnlyFormData, diff --git a/yarn.lock b/yarn.lock index fb55f38940..6d69b670df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36431,3 +36431,4 @@ zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw== + \ No newline at end of file