From 4062b6c6c53c840e2f125c0a54de4a1c5ca85a2d Mon Sep 17 00:00:00 2001 From: Jason Lin <98117700+JasonLin0991@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:27:30 -0500 Subject: [PATCH] MCR-2541: CMS notified when a state uploads response (#2102) * send Q&A response emailer function and template. * Add Q&A helpers. * Remove console log. * Use helpers to remove duplicate code. * email notification technical doc. * Change insertQuestionResponse to insert from question via nested writes. Update * Fix update graphQL types to return entire question when creating response. * Update apollo cache for create response mutation and add comments on reason for manual cache update. * Send new response email in resolver. * Some merge fixes and updating tests. * Update error message. * Move getQuestionRound and add and put tests in describe blocks. * Add helpers for mock Q&A data. * Send new response email tests. * Use mock helpers * cypress re-run * Update documentation * Fix test name --- docs/technical-design/email-notifications.md | 99 ++++++++++ services/app-api/src/emailer/emailer.ts | 103 ++++++---- .../sendQuestionCMSEmail.test.ts.snap | 3 +- .../sendQuestionResponseCMSEmail.test.ts.snap | 11 ++ services/app-api/src/emailer/emails/index.ts | 1 + .../emails/sendQuestionCMSEmail.test.ts | 17 +- .../emailer/emails/sendQuestionCMSEmail.ts | 11 +- .../sendQuestionResponseCMSEmail.test.ts | 144 ++++++++++++++ .../emails/sendQuestionResponseCMSEmail.ts | 91 +++++++++ .../etaTemplates/sendQuestionCMSEmail.eta | 4 +- .../sendQuestionResponseCMSEmail.eta | 6 + services/app-api/src/emailer/index.ts | 1 + .../src/emailer/templateHelpers.test.ts | 177 +++++++++++++++--- .../app-api/src/emailer/templateHelpers.ts | 30 +++ .../app-api/src/postgres/postgresStore.ts | 11 +- .../findAllQuestionsByContract.ts | 36 +--- .../questionResponse/insertQuestion.ts | 29 +-- .../insertQuestionResponse.ts | 44 ++--- .../questionResponse/questionHelpers.ts | 42 ++++- .../src/resolvers/configureResolvers.ts | 6 +- .../submitHealthPlanPackage.ts | 1 - .../createQuestionResponse.test.ts | 131 ++++++++++--- .../createQuestionResponse.ts | 106 ++++++++++- .../questionResponse/indexQuestions.test.ts | 51 +---- .../app-api/src/testHelpers/emailerHelpers.ts | 71 +++++-- .../app-api/src/testHelpers/gqlHelpers.ts | 4 +- .../mutations/createQuestionResponse.graphql | 38 +++- services/app-graphql/src/schema.graphql | 4 +- .../mutationWrappersForUserFriendlyErrors.ts | 8 +- 29 files changed, 1008 insertions(+), 272 deletions(-) create mode 100644 docs/technical-design/email-notifications.md create mode 100644 services/app-api/src/emailer/emails/__snapshots__/sendQuestionResponseCMSEmail.test.ts.snap create mode 100644 services/app-api/src/emailer/emails/sendQuestionResponseCMSEmail.test.ts create mode 100644 services/app-api/src/emailer/emails/sendQuestionResponseCMSEmail.ts create mode 100644 services/app-api/src/emailer/etaTemplates/sendQuestionResponseCMSEmail.eta diff --git a/docs/technical-design/email-notifications.md b/docs/technical-design/email-notifications.md new file mode 100644 index 0000000000..250a2a4375 --- /dev/null +++ b/docs/technical-design/email-notifications.md @@ -0,0 +1,99 @@ +# Email Notifications + +Certain contract actions in the MC-Review app will initiate email notifications sent to the State and CMS. These actions include: +- New contract submission +- Unlocking a submitted contract +- Resubmitting an unlocked contract +- New Q&A question submitted +- New Q&A response submitted + +The email recipients for each action vary depending on contract data, such as contract type, the state the contract applies to, and the designated state contacts for the contract. The CMS email recipients for contract submission, unlock, and resubmission are generated using the helper function `generateCMSReviewerEmails`. + +### New contract submission +- #### State email receivers: + - **stateContacts**: All contacts in the latest submitted contract revision's `formData.stateContacts`. + - **submitterEmails**: User emails that submitted/resubmitted the contract. In resolvers, `submitterEmails` is generated by `contractSubmitters` function that loops through revisions and adding submitInfo.updatedBy emails to a string array. + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. +- #### CMS email receivers: + - `CONTRACT_ONLY` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **dmcpEmails**: DMCP primary inbox from parameter store `/configuration/email/dmcp`. + - `CONTRACT_AND_RATES` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **dmcpEmails**: DMCP primary inbox from parameter store `/configuration/email/dmcp`. + - **oactEmails**: OACT primary inbox from parameter store `/configuration/email/oact`. + - `CHIP` and State of `PR` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. +### Unlocking a submitted contract +- #### State email receivers: + - **stateContacts**: All contacts in the latest submitted contract revision's `formData.stateContacts`. + - **submitterEmails**: User emails that submitted/resubmitted the contract. In resolvers, `submitterEmails` is generated by `contractSubmitters` function that loops through revisions and adding submitInfo.updatedBy emails to a string array. + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. +- #### CMS email receivers: + - `CONTRACT_ONLY` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **dmcpEmails**: DMCP primary inbox from parameter store `/configuration/email/dmcp`. + - `CONTRACT_AND_RATES` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **dmcpEmails**: DMCP primary inbox from parameter store `/configuration/email/dmcp`. + - **oactEmails**: OACT primary inbox from parameter store `/configuration/email/oact`. + - `CHIP` and State of `PR` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. +### Resubmitting an unlocked contract +- #### State email receivers: + - **stateContacts**: All contacts in the latest submitted contract revision's `formData.stateContacts`. + - **submitterEmails**: User emails that submitted/resubmitted the contract. In resolvers, `submitterEmails` is generated by `contractSubmitters` function that loops through revisions and adding submitInfo.updatedBy emails to a string array. + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. +- #### CMS email receivers: + - `CONTRACT_ONLY` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **dmcpEmails**: DMCP primary inbox from parameter store `/configuration/email/dmcp`. + - `CONTRACT_AND_RATES` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **dmcpEmails**: DMCP primary inbox from parameter store `/configuration/email/dmcp`. + - **oactEmails**: OACT primary inbox from parameter store `/configuration/email/oact`. + - `CHIP` and State of `PR` submissions + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. +### New Q&A question submitted +- #### State email receivers: + - **stateContacts**: All contacts in the latest submitted contract revision's `formData.stateContacts`. + - **submitterEmails**: User emails that submitted/resubmitted the contract. In resolvers, `submitterEmails` is generated by `contractSubmitters` function that loops through revisions and adding submitInfo.updatedBy emails to a string array. + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. +- #### CMS email receivers: + - Questions from `DMCO` + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - Questions from `DMCP` + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **dmcpReviewEmails**: DMCP inbox for external communication and Q&A notifications `/configuration/email/dmcpReview`. + - Questions from `OACT` + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **oactEmails**: OACT primary inbox from parameter store `/configuration/email/oact`. +### New Q&A response submitted +- #### State email receivers: + - **stateContacts**: All contacts in the latest submitted contract revision's `formData.stateContacts`. + - **submitterEmails**: User emails that submitted/resubmitted the contract. In resolvers, `submitterEmails` is generated by `contractSubmitters` function that loops through revisions and adding submitInfo.updatedBy emails to a string array. + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. +- #### CMS email receivers: + - Responses to Questions from `DMCO` + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - Responses to Questions from `DMCP` + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **dmcpReviewEmails**: DMCP inbox for external communication and Q&A notifications `/configuration/email/dmcpReview`. + - Responses to Questions from `OACT` + - **devReviewTeamEmails**: Dev team email address from parameter store `/configuration/email/reviewTeamAddresses`. + - **stateAnalystsEmails**: specific state analyst emails from parameter store `/configuration/[STATE]/stateanalysts/email`. + - **oactEmails**: OACT primary inbox from parameter store `/configuration/email/oact`. diff --git a/services/app-api/src/emailer/emailer.ts b/services/app-api/src/emailer/emailer.ts index 27e58e3d88..d5477ca987 100644 --- a/services/app-api/src/emailer/emailer.ts +++ b/services/app-api/src/emailer/emailer.ts @@ -10,6 +10,7 @@ import { resubmitPackageCMSEmail, sendQuestionStateEmail, sendQuestionCMSEmail, + sendQuestionResponseCMSEmail, } from './' import type { LockedHealthPlanFormDataType, @@ -91,6 +92,18 @@ type Emailer = { statePrograms: ProgramType[], submitterEmails: string[] ) => Promise + sendResubmittedStateEmail: ( + formData: LockedHealthPlanFormDataType, + updateInfo: UpdateInfoType, + submitterEmails: string[], + statePrograms: ProgramType[] + ) => Promise + sendResubmittedCMSEmail: ( + formData: LockedHealthPlanFormDataType, + updateInfo: UpdateInfoType, + stateAnalystsEmails: StateAnalystsEmails, + statePrograms: ProgramType[] + ) => Promise sendQuestionsStateEmail: ( contract: ContractRevisionWithRatesType, submitterEmails: string[], @@ -103,17 +116,12 @@ type Emailer = { statePrograms: ProgramType[], questions: Question[] ) => Promise - sendResubmittedStateEmail: ( - formData: LockedHealthPlanFormDataType, - updateInfo: UpdateInfoType, - submitterEmails: string[], - statePrograms: ProgramType[] - ) => Promise - sendResubmittedCMSEmail: ( - formData: LockedHealthPlanFormDataType, - updateInfo: UpdateInfoType, + sendQuestionResponseCMSEmail: ( + contractRevision: ContractRevisionWithRatesType, + statePrograms: ProgramType[], stateAnalystsEmails: StateAnalystsEmails, - statePrograms: ProgramType[] + currentQuestion: Question, + allContractQuestions: Question[] ) => Promise } const localEmailerLogger = (emailData: EmailData) => @@ -203,6 +211,44 @@ function emailer( return await this.sendEmail(emailData) } }, + sendResubmittedStateEmail: async function ( + formData, + updateInfo, + submitterEmails, + statePrograms + ) { + const emailData = await resubmitPackageStateEmail( + formData, + 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) + } + }, sendQuestionsStateEmail: async function ( contract, submitterEmails, @@ -241,37 +287,20 @@ function emailer( return await this.sendEmail(emailData) } }, - sendResubmittedStateEmail: async function ( - formData, - updateInfo, - submitterEmails, - statePrograms - ) { - const emailData = await resubmitPackageStateEmail( - formData, - submitterEmails, - updateInfo, - config, - statePrograms - ) - if (emailData instanceof Error) { - return emailData - } else { - return await this.sendEmail(emailData) - } - }, - sendResubmittedCMSEmail: async function ( - formData, - updateInfo, + sendQuestionResponseCMSEmail: async function ( + contractRevision, + statePrograms, stateAnalystsEmails, - statePrograms + currentQuestion, + allContractQuestions ) { - const emailData = await resubmitPackageCMSEmail( - formData, - updateInfo, + const emailData = await sendQuestionResponseCMSEmail( + contractRevision, config, + statePrograms, stateAnalystsEmails, - statePrograms + currentQuestion, + allContractQuestions ) if (emailData instanceof Error) { return emailData diff --git a/services/app-api/src/emailer/emails/__snapshots__/sendQuestionCMSEmail.test.ts.snap b/services/app-api/src/emailer/emails/__snapshots__/sendQuestionCMSEmail.test.ts.snap index db97dd1212..82115559bd 100644 --- a/services/app-api/src/emailer/emails/__snapshots__/sendQuestionCMSEmail.test.ts.snap +++ b/services/app-api/src/emailer/emails/__snapshots__/sendQuestionCMSEmail.test.ts.snap @@ -7,5 +7,6 @@ exports[`renders overall email for a new question as expected 1`] = ` Round: 1
Date: 01/01/2024

-View submission Q&A" +View submission Q&A +" `; diff --git a/services/app-api/src/emailer/emails/__snapshots__/sendQuestionResponseCMSEmail.test.ts.snap b/services/app-api/src/emailer/emails/__snapshots__/sendQuestionResponseCMSEmail.test.ts.snap new file mode 100644 index 0000000000..24c38425bb --- /dev/null +++ b/services/app-api/src/emailer/emails/__snapshots__/sendQuestionResponseCMSEmail.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders overall CMS email for a new state response as expected 1`] = ` +"The state submitted responses to OACT's questions about MCR-MN-0003-SNBC
+Submitted by: James Brown james@example.com
+Round: 2
+Questions sent on: 02/03/2024
+
+View submission Q&A +" +`; diff --git a/services/app-api/src/emailer/emails/index.ts b/services/app-api/src/emailer/emails/index.ts index c7628c3d5e..c5b3dd359a 100644 --- a/services/app-api/src/emailer/emails/index.ts +++ b/services/app-api/src/emailer/emails/index.ts @@ -6,3 +6,4 @@ export { resubmitPackageCMSEmail } from './resubmitPackageCMSEmail' export { resubmitPackageStateEmail } from './resubmitPackageStateEmail' export { sendQuestionStateEmail } from './sendQuestionStateEmail' export { sendQuestionCMSEmail } from './sendQuestionCMSEmail' +export { sendQuestionResponseCMSEmail } from './sendQuestionResponseCMSEmail' diff --git a/services/app-api/src/emailer/emails/sendQuestionCMSEmail.test.ts b/services/app-api/src/emailer/emails/sendQuestionCMSEmail.test.ts index f9cca51a62..daefe7e903 100644 --- a/services/app-api/src/emailer/emails/sendQuestionCMSEmail.test.ts +++ b/services/app-api/src/emailer/emails/sendQuestionCMSEmail.test.ts @@ -2,15 +2,14 @@ import { testEmailConfig, mockContractRev, mockMNState, + mockQuestionAndResponses, } from '../../testHelpers/emailerHelpers' import type { CMSUserType, StateType, Question } from '../../domain-models' import { packageName } from 'app-web/src/common-code/healthPlanFormDataType' import { sendQuestionCMSEmail } from './index' +import { getTestStateAnalystsEmails } from '../../testHelpers/parameterStoreHelpers' -const stateAnalysts = [ - 'stateAnalysts1@example.com', - 'stateAnalysts1@example.com', -] +const stateAnalysts = getTestStateAnalystsEmails('FL') const flState: StateType = { stateCode: 'FL', @@ -28,15 +27,11 @@ const cmsUser: CMSUserType = { } const questions: Question[] = [ - { - id: '1234', - contractID: 'contract-id-test', - createdAt: new Date('01/01/2024'), + mockQuestionAndResponses({ + id: 'test-question-id-1', addedBy: cmsUser, - documents: [], division: 'DMCO', - responses: [], - }, + }), ] test('to addresses list only includes state analyst when a DMCO user submits a question', async () => { diff --git a/services/app-api/src/emailer/emails/sendQuestionCMSEmail.ts b/services/app-api/src/emailer/emails/sendQuestionCMSEmail.ts index d7d1838236..c8ae547ec5 100644 --- a/services/app-api/src/emailer/emails/sendQuestionCMSEmail.ts +++ b/services/app-api/src/emailer/emails/sendQuestionCMSEmail.ts @@ -7,6 +7,7 @@ import { stripHTMLFromTemplate, renderTemplate, findContractPrograms, + getQuestionRound, } from '../templateHelpers' import { submissionQuestionResponseURL } from '../generateURLs' import type { ContractRevisionWithRatesType } from '../../domain-models/contractAndRates' @@ -44,9 +45,11 @@ export const sendQuestionCMSEmail = async ( contractRev.contract.id, config.baseUrl ) - const roundNumber = questions.filter( - (question) => question.division === newQuestion.division - ).length + const questionRound = getQuestionRound(questions, newQuestion) + + if (questionRound instanceof Error) { + return questionRound + } const data = { packageName, @@ -55,7 +58,7 @@ export const sendQuestionCMSEmail = async ( cmsRequestorName: `${newQuestion.addedBy.givenName} ${newQuestion.addedBy.familyName}`, cmsRequestorDivision: newQuestion.addedBy.divisionAssignment, dateAsked: formatCalendarDate(newQuestion.createdAt), - roundNumber, + questionRound, } const result = await renderTemplate( diff --git a/services/app-api/src/emailer/emails/sendQuestionResponseCMSEmail.test.ts b/services/app-api/src/emailer/emails/sendQuestionResponseCMSEmail.test.ts new file mode 100644 index 0000000000..f9b7b344ad --- /dev/null +++ b/services/app-api/src/emailer/emails/sendQuestionResponseCMSEmail.test.ts @@ -0,0 +1,144 @@ +import { + mockContractRev, + mockMNState, + mockQuestionAndResponses, + testEmailConfig, +} from '../../testHelpers/emailerHelpers' +import { getTestStateAnalystsEmails } from '../../testHelpers/parameterStoreHelpers' +import { testCMSUser } from '../../testHelpers/userHelpers' +import { sendQuestionResponseCMSEmail } from './sendQuestionResponseCMSEmail' + +const stateAnalysts = getTestStateAnalystsEmails('FL') +const oactCMSUser = testCMSUser({ + givenName: 'Bob', + familyName: 'Smith', + divisionAssignment: 'OACT', +}) +const dmcpUser = testCMSUser({ + givenName: 'Bob', + familyName: 'Smith', + divisionAssignment: 'DMCP', +}) +const contractRev = mockContractRev() +const defaultMNStatePrograms = mockMNState().programs +const questions = [ + mockQuestionAndResponses({ + id: 'test-question-id-4', + createdAt: new Date('02/03/2024'), + contractID: contractRev.id, + addedBy: oactCMSUser, + division: 'OACT', + }), + mockQuestionAndResponses({ + id: 'test-question-id-3', + createdAt: new Date('02/03/2024'), + contractID: contractRev.id, + addedBy: dmcpUser, + division: 'DMCP', + }), + mockQuestionAndResponses({ + id: 'test-question-id-2', + createdAt: new Date('02/03/2024'), + contractID: contractRev.id, + addedBy: dmcpUser, + division: 'DMCO', + }), + mockQuestionAndResponses({ + id: 'test-question-id-1', + createdAt: new Date('01/03/2024'), + contractID: contractRev.id, + addedBy: oactCMSUser, + division: 'OACT', + }), +] + +test.each([ + { + questions, + currentQuestion: mockQuestionAndResponses({ + id: 'test-question-id-4', + createdAt: new Date('02/03/2024'), + contractID: contractRev.id, + addedBy: oactCMSUser, + division: 'OACT', + }), + expectedResult: [ + ...stateAnalysts, + ...testEmailConfig().devReviewTeamEmails, + ...testEmailConfig().oactEmails, + ], + testDescription: 'OACT Q&A response email contains correct recipients', + }, + { + questions, + currentQuestion: mockQuestionAndResponses({ + id: 'test-question-id-3', + createdAt: new Date('02/03/2024'), + contractID: contractRev.id, + addedBy: dmcpUser, + division: 'DMCP', + }), + expectedResult: [ + ...stateAnalysts, + ...testEmailConfig().devReviewTeamEmails, + ...testEmailConfig().dmcpReviewEmails, + ], + testDescription: 'DMCP Q&A response email contains correct recipients', + }, + { + questions, + currentQuestion: mockQuestionAndResponses({ + id: 'test-question-id-2', + createdAt: new Date('02/03/2024'), + contractID: contractRev.id, + addedBy: dmcpUser, + division: 'DMCO', + }), + expectedResult: [ + ...stateAnalysts, + ...testEmailConfig().devReviewTeamEmails, + ], + testDescription: 'DMCO Q&A response email contains correct recipients', + }, +])( + '$testDescription', + async ({ questions, currentQuestion, expectedResult }) => { + const result = await sendQuestionResponseCMSEmail( + contractRev, + testEmailConfig(), + defaultMNStatePrograms, + stateAnalysts, + currentQuestion, + questions + ) + + if (result instanceof Error) { + throw new Error(`Unexpect error: ${result.message}`) + } + + expect(result).toEqual( + expect.objectContaining({ + toAddresses: expect.arrayContaining(expectedResult), + }) + ) + } +) + +test('renders overall CMS email for a new state response as expected', async () => { + const currentQuestion = questions[0] + + const result = await sendQuestionResponseCMSEmail( + contractRev, + testEmailConfig(), + defaultMNStatePrograms, + stateAnalysts, + currentQuestion, + questions + ) + + if (result instanceof Error) { + throw new Error(`Unexpect error: ${result.message}`) + } + + expect(result.bodyHTML).toMatchSnapshot() +}) diff --git a/services/app-api/src/emailer/emails/sendQuestionResponseCMSEmail.ts b/services/app-api/src/emailer/emails/sendQuestionResponseCMSEmail.ts new file mode 100644 index 0000000000..e0fbab73a9 --- /dev/null +++ b/services/app-api/src/emailer/emails/sendQuestionResponseCMSEmail.ts @@ -0,0 +1,91 @@ +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, Question } from '../../domain-models' +import { + stripHTMLFromTemplate, + renderTemplate, + findContractPrograms, + getQuestionRound, +} from '../templateHelpers' +import { submissionQuestionResponseURL } from '../generateURLs' +import type { ContractRevisionWithRatesType } from '../../domain-models/contractAndRates' +import type { StateAnalystsEmails } from '..' + +export const sendQuestionResponseCMSEmail = async ( + contractRev: ContractRevisionWithRatesType, + config: EmailConfiguration, + statePrograms: ProgramType[], + stateAnalystsEmails: StateAnalystsEmails, + currentQuestion: Question, + allContractQuestions: Question[] +): Promise => { + // currentQuestion is the question the new response belongs to. Responses can be uploaded to any question round. + const { responses, division } = currentQuestion + const latestResponse = responses[0] + const questionRound = getQuestionRound( + allContractQuestions, + currentQuestion + ) + + if (questionRound instanceof Error) { + return questionRound + } + + let receiverEmails = [...stateAnalystsEmails, ...config.devReviewTeamEmails] + if (division === 'DMCP') { + receiverEmails.push(...config.dmcpReviewEmails) + } else if (division === 'OACT') { + receiverEmails.push(...config.oactEmails) + } + receiverEmails = pruneDuplicateEmails(receiverEmails) + + //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 questionResponseURL = submissionQuestionResponseURL( + contractRev.contract.id, + config.baseUrl + ) + + const data = { + packageName, + questionResponseURL, + cmsRequestorDivision: division, + stateResponseSubmitterEmail: latestResponse.addedBy.email, + stateResponseSubmitterName: `${latestResponse.addedBy.givenName} ${latestResponse.addedBy.familyName}`, + questionRound, + dateAsked: formatCalendarDate(currentQuestion.createdAt), + } + + const result = await renderTemplate( + 'sendQuestionResponseCMSEmail', + data + ) + + if (result instanceof Error) { + return result + } else { + return { + toAddresses: receiverEmails, + sourceEmail: config.emailSource, + replyToAddresses: [config.helpDeskEmail], + subject: `${ + config.stage !== 'prod' ? `[${config.stage}] ` : '' + }New Responses for ${packageName}`, + bodyText: stripHTMLFromTemplate(result), + bodyHTML: result, + } + } +} diff --git a/services/app-api/src/emailer/etaTemplates/sendQuestionCMSEmail.eta b/services/app-api/src/emailer/etaTemplates/sendQuestionCMSEmail.eta index fd394f4ca1..b3079091da 100644 --- a/services/app-api/src/emailer/etaTemplates/sendQuestionCMSEmail.eta +++ b/services/app-api/src/emailer/etaTemplates/sendQuestionCMSEmail.eta @@ -1,8 +1,8 @@ <%= it.cmsRequestorDivision %> sent questions to the state for submission <%= it.packageName %>
Sent by: <%= it.cmsRequestorName %> (<%= it.cmsRequestorDivision %>) <%= it.cmsRequestorEmail %>
-Round: <%= it.roundNumber %> +Round: <%= it.questionRound %>
Date: <%= it.dateAsked %>

-View submission Q&A \ No newline at end of file +View submission Q&A diff --git a/services/app-api/src/emailer/etaTemplates/sendQuestionResponseCMSEmail.eta b/services/app-api/src/emailer/etaTemplates/sendQuestionResponseCMSEmail.eta new file mode 100644 index 0000000000..2f89ca4162 --- /dev/null +++ b/services/app-api/src/emailer/etaTemplates/sendQuestionResponseCMSEmail.eta @@ -0,0 +1,6 @@ +The state submitted responses to <%= it.cmsRequestorDivision %>'s questions about <%= it.packageName %>
+Submitted by: <%= it.stateResponseSubmitterName %> <%= it.stateResponseSubmitterEmail %>
+Round: <%= it.questionRound %>
+Questions sent on: <%= it.dateAsked %>
+
+View submission Q&A diff --git a/services/app-api/src/emailer/index.ts b/services/app-api/src/emailer/index.ts index 443b7b72c8..b665f7af6e 100644 --- a/services/app-api/src/emailer/index.ts +++ b/services/app-api/src/emailer/index.ts @@ -9,6 +9,7 @@ export { resubmitPackageCMSEmail, sendQuestionStateEmail, sendQuestionCMSEmail, + sendQuestionResponseCMSEmail, } 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 ceb1aceeb3..7745872a6e 100644 --- a/services/app-api/src/emailer/templateHelpers.test.ts +++ b/services/app-api/src/emailer/templateHelpers.test.ts @@ -2,6 +2,7 @@ import { filterChipAndPRSubmissionReviewers, findContractPrograms, generateCMSReviewerEmails, + getQuestionRound, handleAsCHIPSubmission, } from './templateHelpers' import type { UnlockedHealthPlanFormDataType } from '../../../app-web/src/common-code/healthPlanFormDataType' @@ -11,11 +12,12 @@ import { mockContractRev, testEmailConfig, testStateAnalystsEmails, + mockQuestionAndResponses, } from '../testHelpers/emailerHelpers' import type { EmailConfiguration, StateAnalystsEmails } from './emailer' import type { ProgramType } from '../domain-models' -describe('templateHelpers', () => { +describe('generateCMSReviewerEmails', () => { const contractOnlyWithValidRateData: { submission: UnlockedHealthPlanFormDataType emailConfig: EmailConfiguration @@ -27,7 +29,7 @@ describe('templateHelpers', () => { submission: mockUnlockedContractOnlyFormData(), emailConfig: testEmailConfig(), stateAnalystsEmails: testStateAnalystsEmails, - testDescription: 'Contract only submission', + testDescription: 'contract only submission', expectedResult: [ ...testEmailConfig().devReviewTeamEmails, ...testStateAnalystsEmails, @@ -38,7 +40,7 @@ describe('templateHelpers', () => { submission: mockUnlockedContractAndRatesFormData(), emailConfig: testEmailConfig(), stateAnalystsEmails: testStateAnalystsEmails, - testDescription: 'Contract and rates submission', + testDescription: 'contract and rates submission', expectedResult: [ ...testEmailConfig().devReviewTeamEmails, ...testStateAnalystsEmails, @@ -54,7 +56,7 @@ describe('templateHelpers', () => { emailConfig: testEmailConfig(), stateAnalystsEmails: testStateAnalystsEmails, testDescription: - 'Submission with CHIP program specified for contract certification', + 'submission with CHIP program specified for contract certification', expectedResult: [ 'devreview1@example.com', 'devreview2@example.com', @@ -100,7 +102,7 @@ describe('templateHelpers', () => { emailConfig: testEmailConfig(), stateAnalystsEmails: testStateAnalystsEmails, testDescription: - 'Submission with CHIP program specified for rate certification', + 'submission with CHIP program specified for rate certification', expectedResult: [ 'devreview1@example.com', 'devreview2@example.com', @@ -128,14 +130,14 @@ describe('templateHelpers', () => { }), emailConfig: testEmailConfig(), stateAnalystsEmails: testStateAnalystsEmails, - testDescription: 'Error result.', + testDescription: 'error result.', expectedResult: new Error( - `generateCMSReviewerEmails does not currently support submission type: undefined.` + `does not currently support submission type: undefined.` ), }, ] test.each(contractOnlyWithValidRateData)( - 'Generate CMS Reviewer email: $testDescription', + '$testDescription', ({ submission, emailConfig, stateAnalystsEmails, expectedResult }) => { expect( generateCMSReviewerEmails( @@ -146,7 +148,9 @@ describe('templateHelpers', () => { ).toEqual(expect.objectContaining(expectedResult)) } ) +}) +describe('handleAsCHIPSubmission', () => { test.each([ { pkg: mockUnlockedContractAndRatesFormData({ @@ -180,13 +184,12 @@ describe('templateHelpers', () => { testDescription: 'for non CHIP submission', expectedResult: false, }, - ])( - 'handleAsCHIPSubmission: $testDescription', - ({ pkg, expectedResult }) => { - expect(handleAsCHIPSubmission(pkg)).toEqual(expectedResult) - } - ) + ])('$testDescription', ({ pkg, expectedResult }) => { + expect(handleAsCHIPSubmission(pkg)).toEqual(expectedResult) + }) +}) +describe('filterChipAndPRSubmissionReviewers', () => { test.each([ { reviewers: [ @@ -211,15 +214,15 @@ describe('templateHelpers', () => { 'Lucille.Bluth@example.com', ], }, - ])( - 'filterChipAndPRSubmissionReviewers: $testDescription', - ({ reviewers, config, expectedResult }) => { - expect( - filterChipAndPRSubmissionReviewers(reviewers, config) - ).toEqual(expectedResult) - } - ) - test('findContractPrograms successfully returns programs for a contract', async () => { + ])('$testDescription', ({ reviewers, config, expectedResult }) => { + expect(filterChipAndPRSubmissionReviewers(reviewers, config)).toEqual( + expectedResult + ) + }) +}) + +describe('findContractPrograms', () => { + test('successfully returns programs for a contract', async () => { const sub = mockContractRev() const statePrograms: [ProgramType] = [ { @@ -234,7 +237,7 @@ describe('templateHelpers', () => { expect(programs).toEqual(statePrograms) }) - test('findContractPrograms throws error if state and contract program ids do not match', async () => { + test('throws error if state and contract program ids do not match', async () => { const sub = mockContractRev() const statePrograms: [ProgramType] = [ { @@ -253,3 +256,129 @@ describe('templateHelpers', () => { ) }) }) + +describe('getQuestionRound', () => { + test.each([ + { + questions: [ + mockQuestionAndResponses({ + id: 'target-question', + division: 'OACT', + createdAt: new Date('03/01/2024'), + }), + mockQuestionAndResponses({ + division: 'DMCO', + createdAt: new Date('03/01/2024'), + }), + mockQuestionAndResponses({ + division: 'OACT', + createdAt: new Date('02/01/2024'), + }), + mockQuestionAndResponses({ + division: 'OACT', + createdAt: new Date('01/01/2024'), + }), + ], + currentQuestion: mockQuestionAndResponses({ + id: 'target-question', + division: 'OACT', + createdAt: new Date('03/01/2024'), + }), + expectedResult: 3, + testDescription: 'Gets correct round for latest question from OACT', + }, + { + questions: [ + mockQuestionAndResponses({ + division: 'OACT', + createdAt: new Date('03/01/2024'), + }), + mockQuestionAndResponses({ + division: 'DMCO', + createdAt: new Date('03/01/2024'), + }), + mockQuestionAndResponses({ + id: 'target-question', + division: 'OACT', + createdAt: new Date('02/01/2024'), + }), + mockQuestionAndResponses({ + division: 'OACT', + createdAt: new Date('01/01/2024'), + }), + ], + currentQuestion: mockQuestionAndResponses({ + id: 'target-question', + division: 'OACT', + createdAt: new Date('03/01/2024'), + }), + expectedResult: 2, + testDescription: 'Gets correct round for second question from OACT', + }, + { + questions: [ + mockQuestionAndResponses({ + division: 'OACT', + createdAt: new Date('03/01/2024'), + }), + mockQuestionAndResponses({ + division: 'DMCO', + createdAt: new Date('03/01/2024'), + }), + mockQuestionAndResponses({ + id: 'target-question', + division: 'OACT', + createdAt: new Date('02/01/2024'), + }), + mockQuestionAndResponses({ + division: 'OACT', + createdAt: new Date('01/01/2024'), + }), + ], + currentQuestion: mockQuestionAndResponses({ + id: 'not-found-question', + division: 'OACT', + createdAt: new Date('03/01/2024'), + }), + expectedResult: new Error( + 'Error getting question round, current question index not found' + ), + testDescription: + 'Returns error if question is not found in questions', + }, + { + questions: [ + mockQuestionAndResponses({ + division: 'OACT', + createdAt: new Date('03/01/2024'), + }), + mockQuestionAndResponses({ + division: 'DMCO', + createdAt: new Date('03/01/2024'), + }), + mockQuestionAndResponses({ + id: 'target-question', + division: 'OACT', + createdAt: new Date('02/01/2024'), + }), + mockQuestionAndResponses({ + division: 'OACT', + createdAt: new Date('01/01/2024'), + }), + ], + currentQuestion: mockQuestionAndResponses({ + id: 'not-found-question', + division: 'DMCP', + createdAt: new Date('03/01/2024'), + }), + expectedResult: new Error( + 'Error getting question round, current question not found' + ), + testDescription: 'Returns error if divison has no questions', + }, + ])('$testDescription', ({ questions, currentQuestion, expectedResult }) => { + expect(getQuestionRound(questions, currentQuestion)).toEqual( + expectedResult + ) + }) +}) diff --git a/services/app-api/src/emailer/templateHelpers.ts b/services/app-api/src/emailer/templateHelpers.ts index 7493658cb7..d8fa02d92f 100644 --- a/services/app-api/src/emailer/templateHelpers.ts +++ b/services/app-api/src/emailer/templateHelpers.ts @@ -13,6 +13,7 @@ import type { } from '../domain-models' import { logError } from '../logger' import { pruneDuplicateEmails } from './formatters' +import type { Question } from '../domain-models' // ETA SETUP Eta.configure({ @@ -213,6 +214,34 @@ const stripHTMLFromTemplate = (template: string) => { return formatted.replace(/(<([^>]+)>)/gi, '') } +const getQuestionRound = ( + allQuestions: Question[], + currentQuestion: Question +): number | Error => { + // Filter out other divisions question and sort by created at in ascending order + const divisionQuestions = allQuestions + .filter((question) => question.division === currentQuestion.division) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + + if (divisionQuestions.length === 0) { + return new Error( + 'Error getting question round, current question not found' + ) + } + + // Find index of the current question, this is it's round. First, index 0, in the array is round 1 + const questionIndex = divisionQuestions.findIndex( + (question) => question.id === currentQuestion.id + ) + if (questionIndex === -1) { + return new Error( + 'Error getting question round, current question index not found' + ) + } + + return questionIndex + 1 +} + export { stripHTMLFromTemplate, handleAsCHIPSubmission, @@ -223,4 +252,5 @@ export { findPackagePrograms, findContractPrograms, filterChipAndPRSubmissionReviewers, + getQuestionRound, } diff --git a/services/app-api/src/postgres/postgresStore.ts b/services/app-api/src/postgres/postgresStore.ts index 5c63667485..6d7c661d1b 100644 --- a/services/app-api/src/postgres/postgresStore.ts +++ b/services/app-api/src/postgres/postgresStore.ts @@ -7,7 +7,6 @@ import type { StateUserType, Question, CreateQuestionInput, - QuestionResponseType, InsertQuestionResponseArgs, StateType, RateType, @@ -93,11 +92,8 @@ type Store = { insertQuestionResponse: ( questionInput: InsertQuestionResponseArgs, user: StateUserType - ) => Promise + ) => Promise - /** - * Rates database refactor prisma functions - */ insertDraftContract: ( args: InsertContractArgsType ) => Promise @@ -163,15 +159,14 @@ function NewPostgresStore(client: PrismaClient): Store { findStatePrograms: findStatePrograms, findAllSupportedStates: () => findAllSupportedStates(client), findAllUsers: () => findAllUsers(client), + insertQuestion: (questionInput, user) => insertQuestion(client, questionInput, user), findAllQuestionsByContract: (pkgID) => findAllQuestionsByContract(client, pkgID), insertQuestionResponse: (questionInput, user) => insertQuestionResponse(client, questionInput, user), - /** - * Rates database refactor prisma functions - */ + insertDraftContract: (args) => insertDraftContract(client, args), findContractWithHistory: (args) => findContractWithHistory(client, args), diff --git a/services/app-api/src/postgres/questionResponse/findAllQuestionsByContract.ts b/services/app-api/src/postgres/questionResponse/findAllQuestionsByContract.ts index 9f24fa4f07..bb3dce6bed 100644 --- a/services/app-api/src/postgres/questionResponse/findAllQuestionsByContract.ts +++ b/services/app-api/src/postgres/questionResponse/findAllQuestionsByContract.ts @@ -1,9 +1,6 @@ import type { PrismaClient } from '@prisma/client' -import type { - CMSUserType, - Question, - QuestionResponseType, -} from '../../domain-models' +import type { Question } from '../../domain-models' +import { questionPrismaToDomainType, questionInclude } from './questionHelpers' export async function findAllQuestionsByContract( client: PrismaClient, @@ -14,36 +11,15 @@ export async function findAllQuestionsByContract( where: { contractID: contractID, }, - include: { - documents: { - orderBy: { - createdAt: 'desc', - }, - }, - responses: { - include: { - addedBy: true, - documents: true, - }, - orderBy: { - createdAt: 'desc', - }, - }, - addedBy: true, - }, + include: questionInclude, orderBy: { createdAt: 'desc', }, }) - const questions: Question[] = findResult.map((question) => ({ - ...question, - addedBy: { - ...question.addedBy, - stateAssignments: [], - } as CMSUserType, - responses: question.responses as QuestionResponseType[], - })) + const questions: Question[] = findResult.map((question) => + questionPrismaToDomainType(question) + ) return questions } catch (e: unknown) { diff --git a/services/app-api/src/postgres/questionResponse/insertQuestion.ts b/services/app-api/src/postgres/questionResponse/insertQuestion.ts index 17d730bcdc..2ba50c59c8 100644 --- a/services/app-api/src/postgres/questionResponse/insertQuestion.ts +++ b/services/app-api/src/postgres/questionResponse/insertQuestion.ts @@ -1,7 +1,6 @@ import type { PrismaClient } from '@prisma/client' import type { CMSUserType, - QuestionResponseType, Question, CreateQuestionInput, DivisionType, @@ -9,6 +8,7 @@ import type { import type { StoreError } from '../storeError' import { convertPrismaErrorToStoreError } from '../storeError' import { v4 as uuidv4 } from 'uuid' +import { questionPrismaToDomainType, questionInclude } from './questionHelpers' export async function insertQuestion( client: PrismaClient, @@ -40,33 +40,10 @@ export async function insertQuestion( }, division: user.divisionAssignment as DivisionType, }, - include: { - documents: { - orderBy: { - createdAt: 'desc', - }, - }, - responses: { - include: { - addedBy: true, - documents: true, - }, - orderBy: { - createdAt: 'desc', - }, - }, - }, + include: questionInclude, }) - const createdQuestion: Question = { - ...result, - addedBy: user, - responses: result.responses.map( - (response) => response as QuestionResponseType - ), - } - - return createdQuestion + return questionPrismaToDomainType(result) } catch (e: unknown) { return convertPrismaErrorToStoreError(e) } diff --git a/services/app-api/src/postgres/questionResponse/insertQuestionResponse.ts b/services/app-api/src/postgres/questionResponse/insertQuestionResponse.ts index b27b5652ec..bc60a7f4b0 100644 --- a/services/app-api/src/postgres/questionResponse/insertQuestionResponse.ts +++ b/services/app-api/src/postgres/questionResponse/insertQuestionResponse.ts @@ -3,16 +3,17 @@ import type { StoreError } from '../storeError' import { convertPrismaErrorToStoreError } from '../storeError' import type { InsertQuestionResponseArgs, - QuestionResponseType, StateUserType, + Question, } from '../../domain-models' import { v4 as uuidv4 } from 'uuid' +import { questionInclude, questionPrismaToDomainType } from './questionHelpers' export async function insertQuestionResponse( client: PrismaClient, response: InsertQuestionResponseArgs, user: StateUserType -): Promise { +): Promise { const documents = response.documents.map((document) => ({ id: uuidv4(), name: document.name, @@ -20,34 +21,29 @@ export async function insertQuestionResponse( })) try { - const result = await client.questionResponse.create({ + const result = await client.question.update({ + where: { + id: response.questionID, + }, data: { - id: uuidv4(), - question: { - connect: { - id: response.questionID, - }, - }, - addedBy: { - connect: { - id: user.id, + responses: { + create: { + id: uuidv4(), + addedBy: { + connect: { + id: user.id, + }, + }, + documents: { + create: documents, + }, }, }, - documents: { - create: documents, - }, - }, - include: { - documents: true, }, + include: questionInclude, }) - const createdResponse: QuestionResponseType = { - ...result, - addedBy: user, - } - - return createdResponse + return questionPrismaToDomainType(result) } catch (e: unknown) { return convertPrismaErrorToStoreError(e) } diff --git a/services/app-api/src/postgres/questionResponse/questionHelpers.ts b/services/app-api/src/postgres/questionResponse/questionHelpers.ts index f5af64b519..af1656f42e 100644 --- a/services/app-api/src/postgres/questionResponse/questionHelpers.ts +++ b/services/app-api/src/postgres/questionResponse/questionHelpers.ts @@ -1,6 +1,40 @@ import type { IndexQuestionsPayload, Question } from '../../domain-models' +import type { Prisma } from '@prisma/client' -export const convertToIndexQuestionsPayload = ( +const questionInclude = { + documents: { + orderBy: { + createdAt: 'desc', + }, + }, + responses: { + include: { + addedBy: true, + documents: true, + }, + orderBy: { + createdAt: 'desc', + }, + }, + addedBy: true, +} satisfies Prisma.QuestionInclude + +type PrismaQuestionType = Prisma.QuestionGetPayload<{ + include: typeof questionInclude +}> + +const questionPrismaToDomainType = ( + prismaQuestion: PrismaQuestionType +): Question => ({ + ...prismaQuestion, + addedBy: { + ...prismaQuestion.addedBy, + stateAssignments: [], + } as Question['addedBy'], + responses: prismaQuestion.responses as Question['responses'], +}) + +const convertToIndexQuestionsPayload = ( questions: Question[] ): IndexQuestionsPayload => { const questionsPayload: IndexQuestionsPayload = { @@ -33,3 +67,9 @@ export const convertToIndexQuestionsPayload = ( return questionsPayload } + +export { + questionInclude, + questionPrismaToDomainType, + convertToIndexQuestionsPayload, +} diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 3ff848678b..916bf60f57 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -79,7 +79,11 @@ export function configureResolvers( emailParameterStore, emailer ), - createQuestionResponse: createQuestionResponseResolver(store), + createQuestionResponse: createQuestionResponseResolver( + store, + emailer, + emailParameterStore + ), }, User: { // resolveType is required to differentiate Unions diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index a2165bd0a6..106fbde04b 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -494,7 +494,6 @@ export function submitHealthPlanPackageResolver( let statePackageEmailResult if (status === 'RESUBMITTED') { - logSuccess('It was resubmitted') cmsPackageEmailResult = await emailer.sendResubmittedCMSEmail( lockedFormData, updateInfo, diff --git a/services/app-api/src/resolvers/questionResponse/createQuestionResponse.test.ts b/services/app-api/src/resolvers/questionResponse/createQuestionResponse.test.ts index f92fe85f31..b9ee55c718 100644 --- a/services/app-api/src/resolvers/questionResponse/createQuestionResponse.test.ts +++ b/services/app-api/src/resolvers/questionResponse/createQuestionResponse.test.ts @@ -10,6 +10,11 @@ import { createDBUsersWithFullData, testCMSUser, } from '../../testHelpers/userHelpers' +import { testEmailConfig, testEmailer } from '../../testHelpers/emailerHelpers' +import { latestFormData } from '../../testHelpers/healthPlanPackageHelpers' +import { findStatePrograms } from '../../postgres' +import { packageName } from '../../../../app-web/src/common-code/healthPlanFormDataType' +import { getTestStateAnalystsEmails } from '../../testHelpers/parameterStoreHelpers' describe('createQuestionResponse', () => { const cmsUser = testCMSUser() @@ -34,33 +39,38 @@ describe('createQuestionResponse', () => { submittedPkg.id ) - const createdResponse = await createTestQuestionResponse( + const createResponseResult = await createTestQuestionResponse( stateServer, - createdQuestion?.question.id + createdQuestion.question.id ) - expect(createdResponse).toEqual({ - response: expect.objectContaining({ - id: expect.any(String), - questionID: createdQuestion?.question.id, - documents: [ - { - name: 'Test Question', - s3URL: 'testS3Url', - }, - ], - addedBy: expect.objectContaining({ - role: 'STATE_USER', - }), - }), - }) + expect(createResponseResult.question).toEqual( + expect.objectContaining({ + ...createdQuestion.question, + responses: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + questionID: createdQuestion.question.id, + documents: [ + { + name: 'Test Question Response', + s3URL: 'testS3Url', + }, + ], + addedBy: expect.objectContaining({ + role: 'STATE_USER', + }), + }), + ]), + }) + ) }) it('returns an error when attempting to create response for a question that does not exist', async () => { const stateServer = await constructTestPostgresServer() const fakeID = 'abc-123' - const createdResponse = await stateServer.executeOperation({ + const createResponseResult = await stateServer.executeOperation({ query: CREATE_QUESTION_RESPONSE, variables: { input: { @@ -75,9 +85,9 @@ describe('createQuestionResponse', () => { }, }) - expect(createdResponse.errors).toBeDefined() - expect(assertAnErrorCode(createdResponse)).toBe('BAD_USER_INPUT') - expect(assertAnError(createdResponse).message).toBe( + expect(createResponseResult.errors).toBeDefined() + expect(assertAnErrorCode(createResponseResult)).toBe('BAD_USER_INPUT') + expect(assertAnError(createResponseResult).message).toBe( `Issue creating question response for question ${fakeID} of type NOT_FOUND_ERROR. Message: An operation failed because it depends on one or more records that were required but not found.` ) }) @@ -96,7 +106,7 @@ describe('createQuestionResponse', () => { submittedPkg.id ) - const createdResponse = await cmsServer.executeOperation({ + const createResponseResult = await cmsServer.executeOperation({ query: CREATE_QUESTION_RESPONSE, variables: { input: { @@ -111,10 +121,81 @@ describe('createQuestionResponse', () => { }, }) - expect(createdResponse.errors).toBeDefined() - expect(assertAnErrorCode(createdResponse)).toBe('FORBIDDEN') - expect(assertAnError(createdResponse).message).toBe( + expect(createResponseResult.errors).toBeDefined() + expect(assertAnErrorCode(createResponseResult)).toBe('FORBIDDEN') + expect(assertAnError(createResponseResult).message).toBe( 'user not authorized to create a question response' ) }) + + it('sends CMS email', async () => { + const emailConfig = testEmailConfig() + const mockEmailer = testEmailer(emailConfig) + const oactCMS = testCMSUser({ + divisionAssignment: 'OACT' as const, + }) + const stateServer = await constructTestPostgresServer({ + emailer: mockEmailer, + }) + const cmsServer = await constructTestPostgresServer({ + context: { + user: oactCMS, + }, + emailer: mockEmailer, + }) + + const submittedPkg = + await createAndSubmitTestHealthPlanPackage(stateServer) + + const formData = latestFormData(submittedPkg) + + const createdQuestion = await createTestQuestion(cmsServer, formData.id) + + await createTestQuestionResponse( + stateServer, + createdQuestion?.question.id + ) + + const statePrograms = findStatePrograms(formData.stateCode) + if (statePrograms instanceof Error) { + throw new Error( + `Unexpected error: No state programs found for stateCode ${formData.stateCode}` + ) + } + + const pkgName = packageName( + formData.stateCode, + formData.stateNumber, + formData.programIDs, + statePrograms + ) + + const stateAnalystsEmails = getTestStateAnalystsEmails( + formData.stateCode + ) + const cmsRecipientEmails = [ + ...stateAnalystsEmails, + ...emailConfig.devReviewTeamEmails, + ...emailConfig.oactEmails, + ] + + expect(mockEmailer.sendEmail).toHaveBeenNthCalledWith( + 5, // New response CMS email notification is the fifth email + expect.objectContaining({ + subject: expect.stringContaining( + `[LOCAL] New Responses for ${pkgName}` + ), + sourceEmail: emailConfig.emailSource, + toAddresses: expect.arrayContaining( + Array.from(cmsRecipientEmails) + ), + bodyText: expect.stringContaining( + `The state submitted responses to OACT's questions about ${pkgName}` + ), + bodyHTML: expect.stringContaining( + `View submission Q&A` + ), + }) + ) + }) }) diff --git a/services/app-api/src/resolvers/questionResponse/createQuestionResponse.ts b/services/app-api/src/resolvers/questionResponse/createQuestionResponse.ts index 8a3ffe7511..02c604f7ad 100644 --- a/services/app-api/src/resolvers/questionResponse/createQuestionResponse.ts +++ b/services/app-api/src/resolvers/questionResponse/createQuestionResponse.ts @@ -8,9 +8,15 @@ import { import { ForbiddenError, UserInputError } from 'apollo-server-lambda' import type { Store } from '../../postgres' import { isStoreError } from '../../postgres' +import { GraphQLError } from 'graphql/index' +import { NotFoundError } from '../../postgres' +import type { Emailer } from '../../emailer' +import type { EmailParameterStore } from '../../parameterStore' export function createQuestionResponseResolver( - store: Store + store: Store, + emailer: Emailer, + emailParameterStore: EmailParameterStore ): MutationResolvers['createQuestionResponse'] { return async (_parent, { input }, context) => { const { user, span } = context @@ -29,20 +35,108 @@ export function createQuestionResponseResolver( throw new UserInputError(msg) } - const responseResult = await store.insertQuestionResponse(input, user) - - if (isStoreError(responseResult)) { - const errMessage = `Issue creating question response for question ${input.questionID} of type ${responseResult.code}. Message: ${responseResult.message}` + const createResponseResult = await store.insertQuestionResponse( + input, + user + ) + if (isStoreError(createResponseResult)) { + const errMessage = `Issue creating question response for question ${input.questionID} of type ${createResponseResult.code}. Message: ${createResponseResult.message}` logError('createQuestionResponse', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new UserInputError(errMessage) } + const questions = await store.findAllQuestionsByContract( + createResponseResult.contractID + ) + if (questions instanceof Error) { + const errMessage = `Issue finding all questions for contract with ID ${createResponseResult.contractID}. Message: ${questions.message}` + logError('createQuestionResponse', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + const contract = await store.findContractWithHistory( + createResponseResult.contractID + ) + if (contract instanceof Error) { + if (contract instanceof NotFoundError) { + const errMessage = `Package with id ${createResponseResult.contractID} does not exist` + logError('createQuestionResponse', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { code: 'NOT_FOUND' }, + }) + } + + const errMessage = `Issue finding a package. Message: ${contract.message}` + logError('createQuestionResponse', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + const statePrograms = store.findStatePrograms(contract.stateCode) + if (statePrograms instanceof Error) { + logError('createQuestionResponse', statePrograms.message) + setErrorAttributesOnActiveSpan(statePrograms.message, span) + throw new GraphQLError(statePrograms.message, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + let stateAnalystsEmails = + await emailParameterStore.getStateAnalystsEmails(contract.stateCode) + //If error log it and set stateAnalystsEmails to empty string as to not interrupt the emails. + if (stateAnalystsEmails instanceof Error) { + logError('createQuestionResponse', stateAnalystsEmails.message) + setErrorAttributesOnActiveSpan(stateAnalystsEmails.message, span) + stateAnalystsEmails = [] + } + + const sendQuestionResponseCMSEmailResult = + await emailer.sendQuestionResponseCMSEmail( + contract.revisions[0], + statePrograms, + stateAnalystsEmails, + createResponseResult, + questions + ) + + if (sendQuestionResponseCMSEmailResult instanceof Error) { + logError( + 'sendQuestionResponseCMSEmail - Send CMS email', + sendQuestionResponseCMSEmailResult.message + ) + setErrorAttributesOnActiveSpan( + `Send CMS email failed: ${sendQuestionResponseCMSEmailResult.message}`, + span + ) + throw new GraphQLError('Email failed', { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'EMAIL_ERROR', + }, + }) + } + logSuccess('createQuestionResponse') setSuccessAttributesOnActiveSpan(span) return { - response: responseResult, + question: createResponseResult, } } } diff --git a/services/app-api/src/resolvers/questionResponse/indexQuestions.test.ts b/services/app-api/src/resolvers/questionResponse/indexQuestions.test.ts index 35c5fc3d58..a9ffbb5403 100644 --- a/services/app-api/src/resolvers/questionResponse/indexQuestions.test.ts +++ b/services/app-api/src/resolvers/questionResponse/indexQuestions.test.ts @@ -113,20 +113,9 @@ describe('indexQuestions', () => { totalCount: 1, edges: expect.arrayContaining([ { - node: expect.objectContaining({ - id: expect.any(String), - createdAt: expect.any(Date), - contractID: submittedPkg.id, - division: 'DMCO', - documents: [ - { - name: 'Test Question 1', - s3URL: 'testS3Url1', - }, - ], - addedBy: dmcoCMSUser, - responses: [responseToDMCO.response], - }), + node: expect.objectContaining( + responseToDMCO.question + ), }, ]), }), @@ -134,20 +123,9 @@ describe('indexQuestions', () => { totalCount: 1, edges: [ { - node: expect.objectContaining({ - id: expect.any(String), - createdAt: expect.any(Date), - contractID: submittedPkg.id, - division: 'DMCP', - documents: [ - { - name: 'Test Question 2', - s3URL: 'testS3Url2', - }, - ], - addedBy: dmcpCMSUser, - responses: [responseToDMCP.response], - }), + node: expect.objectContaining( + responseToDMCP.question + ), }, ], }), @@ -155,20 +133,9 @@ describe('indexQuestions', () => { totalCount: 1, edges: [ { - node: expect.objectContaining({ - id: expect.any(String), - createdAt: expect.any(Date), - contractID: submittedPkg.id, - division: 'OACT', - documents: [ - { - name: 'Test Question 3', - s3URL: 'testS3Url3', - }, - ], - addedBy: oactCMSUser, - responses: [responseToOACT.response], - }), + node: expect.objectContaining( + responseToOACT.question + ), }, ], }), diff --git a/services/app-api/src/testHelpers/emailerHelpers.ts b/services/app-api/src/testHelpers/emailerHelpers.ts index 6adac8f41f..8d38101e85 100644 --- a/services/app-api/src/testHelpers/emailerHelpers.ts +++ b/services/app-api/src/testHelpers/emailerHelpers.ts @@ -5,12 +5,11 @@ import type { ProgramArgType, UnlockedHealthPlanFormDataType, } from '../../../app-web/src/common-code/healthPlanFormDataType' -import type { - ContractRevisionWithRatesType, - StateUserType, -} from '../domain-models' +import type { ContractRevisionWithRatesType, Question } from '../domain-models' import { SESServiceException } from '@aws-sdk/client-ses' import { testSendSESEmail } from './awsSESHelpers' +import { testCMSUser, testStateUser } from './userHelpers' +import { v4 as uuidv4 } from 'uuid' const testEmailConfig = (): EmailConfiguration => ({ stage: 'LOCAL', @@ -73,17 +72,6 @@ function testEmailer(customConfig?: EmailConfiguration): Emailer { return emailer(config, jest.fn(sendTestEmails)) } -const mockUser = (): StateUserType => { - return { - id: '6ec0e9a7-b5fc-44c2-a049-2d60ac37c6ee', - role: 'STATE_USER', - email: 'test+state+user@example.com', - stateCode: 'MN', - familyName: 'State', - givenName: 'User', - } -} - type State = { name: string programs: ProgramArgType[] @@ -625,6 +613,57 @@ const mockContractAmendmentFormData = ( } } +const mockQuestionAndResponses = ( + questionData?: Partial +): Question => { + const question: Question = { + id: `test-question-id-1`, + contractID: 'contract-id-test', + createdAt: new Date('01/01/2024'), + addedBy: testCMSUser(), + documents: [ + { + name: 'Test Question', + s3URL: 'testS3Url', + }, + ], + division: 'DMCO', + responses: [], + ...questionData, + } + + const defaultResponses = [ + { + id: uuidv4(), + questionID: question.id, + //Add 1 day to date, to make sure this date is always after question.createdAt + createdAt: ((): Date => { + const responseDate = new Date(question.createdAt) + return new Date( + responseDate.setDate(responseDate.getDate() + 1) + ) + })(), + addedBy: testStateUser(), + documents: [ + { + name: 'Test Question Response', + s3URL: 'testS3Url', + }, + ], + }, + ] + + // If responses are passed in, use that and replace questionIDs, so they match the question. + question.responses = questionData?.responses + ? questionData.responses.map((response) => ({ + ...response, + questionID: question.id, + })) + : defaultResponses + + return question +} + export { testEmailConfig, testStateAnalystsEmails, @@ -636,6 +675,6 @@ export { mockContractAndRatesFormData, mockUnlockedContractAndRatesFormData, mockUnlockedContractOnlyFormData, - mockUser, testEmailer, + mockQuestionAndResponses, } diff --git a/services/app-api/src/testHelpers/gqlHelpers.ts b/services/app-api/src/testHelpers/gqlHelpers.ts index 654f079511..76b3cf4891 100644 --- a/services/app-api/src/testHelpers/gqlHelpers.ts +++ b/services/app-api/src/testHelpers/gqlHelpers.ts @@ -496,7 +496,7 @@ const createTestQuestionResponse = async ( const response = responseData || { documents: [ { - name: 'Test Question', + name: 'Test Question Response', s3URL: 'testS3Url', }, ], @@ -505,8 +505,8 @@ const createTestQuestionResponse = async ( query: CREATE_QUESTION_RESPONSE, variables: { input: { - questionID, ...response, + questionID, }, }, }) diff --git a/services/app-graphql/src/mutations/createQuestionResponse.graphql b/services/app-graphql/src/mutations/createQuestionResponse.graphql index 0c3b15bc94..3b12555e21 100644 --- a/services/app-graphql/src/mutations/createQuestionResponse.graphql +++ b/services/app-graphql/src/mutations/createQuestionResponse.graphql @@ -1,8 +1,8 @@ mutation createQuestionResponse($input: CreateQuestionResponseInput!) { createQuestionResponse(input: $input) { - response { + question { id - questionID + contractID createdAt addedBy { id @@ -10,20 +10,42 @@ mutation createQuestionResponse($input: CreateQuestionResponseInput!) { email givenName familyName - state { + divisionAssignment + stateAssignments { code name - programs { - id - name - fullName - } } } + division documents { name s3URL } + responses { + id + questionID + createdAt + addedBy { + id + role + email + givenName + familyName + state { + code + name + programs { + id + name + fullName + } + } + } + documents { + name + s3URL + } + } } } } diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index bd120a2c04..33fd0b19f4 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -376,8 +376,8 @@ input CreateQuestionResponseInput { } type CreateQuestionResponsePayload { - "The newly created QuestionResponse" - response: QuestionResponse! + "Question with newly created response" + question: Question! } type UserEdge { diff --git a/services/app-web/src/gqlHelpers/mutationWrappersForUserFriendlyErrors.ts b/services/app-web/src/gqlHelpers/mutationWrappersForUserFriendlyErrors.ts index aae2163742..d4cd4c605c 100644 --- a/services/app-web/src/gqlHelpers/mutationWrappersForUserFriendlyErrors.ts +++ b/services/app-web/src/gqlHelpers/mutationWrappersForUserFriendlyErrors.ts @@ -152,6 +152,11 @@ export const submitMutationWrapper = async ( } } +/** + * Manually updating the cache for Q&A mutations because the Q&A page is in a layout route that is not unmounted during the Q&A + * workflow. So, when calling Q&A mutations the Q&A page will not refetch the data. The alternative would be to use + * cache.evict() to force a refetch, but would then cause the loading UI to show. + **/ export const createQuestionWrapper = async ( createQuestion: CreateQuestionMutationFn, input: CreateQuestionInput @@ -246,7 +251,8 @@ export const createResponseWrapper = async ( variables: { input }, update(cache, { data }) { if (data) { - const newResponse = data.createQuestionResponse.response + const newResponse = + data.createQuestionResponse.question.responses[0] const result = cache.readQuery( {