From dab3b07f2f0af73153c46e93cb4a5ddd9072c8cf Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Mon, 28 Aug 2023 13:52:27 -0500 Subject: [PATCH 01/23] submitContract working --- .../findContractWithHistory.test.ts | 125 ++++++++---------- .../findRateWithHistory.test.ts | 88 ++++++------ .../contractAndRates/insertContract.ts | 14 +- .../prismaSubmittedRateHelpers.ts | 14 +- .../contractAndRates/submitContract.test.ts | 55 ++++---- .../contractAndRates/submitContract.ts | 84 ++++++------ .../contractAndRates/unlockContract.test.ts | 87 ++++++------ .../updateHealthPlanFormData.test.ts | 11 +- 8 files changed, 223 insertions(+), 255 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts index 72a1ef17ce..52b45e3d41 100644 --- a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts @@ -45,12 +45,11 @@ describe('findContract', () => { await insertDraftContract(client, draftContractData) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'initial submit' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) ) // Add 3 rates 1, 2, 3 pointing to contract A @@ -166,12 +165,11 @@ describe('findContract', () => { ) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'Submitting A.1' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'Submitting A.1', + }) ) // Now, find that contract and assert the history is what we expected @@ -193,8 +191,7 @@ describe('findContract', () => { ) ) must( - await updateDraftContract( - client,{ + await updateDraftContract(client, { contractID: contractA.id, formData: { submissionType: 'CONTRACT_AND_RATES', @@ -204,16 +201,15 @@ describe('findContract', () => { populationCovered: 'MEDICAID', riskBasedContract: false, }, - rateIDs: [rate3.id]} - ) + rateIDs: [rate3.id], + }) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'Submitting A.2' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'Submitting A.2', + }) ) // Now, find that contract and assert the history is what we expected @@ -351,12 +347,11 @@ describe('findContract', () => { await insertDraftContract(client, draftContractData) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'initial submit' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) ) // Add 3 rates 1, 2, 3 pointing to contract A @@ -448,12 +443,11 @@ describe('findContract', () => { ) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'Submitting A.1' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'Submitting A.1', + }) ) // Make a new Contract Revision, changing the connections should show up as a single new rev. @@ -466,8 +460,7 @@ describe('findContract', () => { ) ) must( - await updateDraftContract( - client,{ + await updateDraftContract(client, { contractID: contractA.id, formData: { submissionType: 'CONTRACT_AND_RATES', @@ -477,16 +470,15 @@ describe('findContract', () => { populationCovered: 'MEDICAID', riskBasedContract: false, }, - rateIDs: [rate3.id]} - ) + rateIDs: [rate3.id], + }) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'Submitting A.2' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'Submitting A.2', + }) ) // Now, find that contract and assert the history is what we expected @@ -613,10 +605,8 @@ describe('findContract', () => { await insertDraftContract(client, draftContractData) ) must( - await updateDraftContract( - client, - { - contractID: contractA.id, + await updateDraftContract(client, { + contractID: contractA.id, formData: { submissionType: 'CONTRACT_AND_RATES', submissionDescription: 'one contract', @@ -625,16 +615,15 @@ describe('findContract', () => { populationCovered: 'MEDICAID', riskBasedContract: false, }, - rateIDs: [rate1.id, rate2.id] - } - )) + rateIDs: [rate1.id, rate2.id], + }) + ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'initial submit' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) ) // Unlock contract A, but don't resubmit it yet. @@ -695,21 +684,19 @@ describe('findContract', () => { // submit contract A1, now, should show up as a single new rev and have the latest rates must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'third submit' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'third submit', + }) ) // attempt a second submission, should result in an error. - const contractA_1_Error = await submitContract( - client, - contractA.id, - stateUser.id, - 'third submit' - ) + const contractA_1_Error = await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'third submit', + }) if (!(contractA_1_Error instanceof Error)) { throw new Error('Should be impossible to submit twice in a row.') } diff --git a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts index 90324f1371..628e7b2e7b 100644 --- a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts @@ -70,12 +70,11 @@ describe('findRate', () => { }) ) must( - await submitContract( - client, - contract1.id, - stateUser.id, - 'Contract Submit' - ) + await submitContract(client, { + contractID: contract1.id, + submittedByUserID: stateUser.id, + submitReason: 'Contract Submit', + }) ) const contract2 = must( @@ -95,12 +94,11 @@ describe('findRate', () => { }) ) must( - await submitContract( - client, - contract2.id, - stateUser.id, - 'ContractSubmit 2' - ) + await submitContract(client, { + contractID: contract2.id, + submittedByUserID: stateUser.id, + submitReason: 'ContractSubmit 2', + }) ) const contract3 = must( @@ -120,12 +118,11 @@ describe('findRate', () => { }) ) must( - await submitContract( - client, - contract3.id, - stateUser.id, - '3.0 create' - ) + await submitContract(client, { + contractID: contract3.id, + submittedByUserID: stateUser.id, + submitReason: '3.0 create', + }) ) // Now, find that rate and assert the history is what we expected @@ -152,12 +149,11 @@ describe('findRate', () => { }) ) must( - await submitContract( - client, - contract2.id, - stateUser.id, - '2.1 remove' - ) + await submitContract(client, { + contractID: contract2.id, + submittedByUserID: stateUser.id, + submitReason: '2.1 remove', + }) ) // Now, find that contract and assert the history is what we expected @@ -185,12 +181,11 @@ describe('findRate', () => { }) ) must( - await submitContract( - client, - contract1.id, - stateUser.id, - '1.1 new name' - ) + await submitContract(client, { + contractID: contract1.id, + submittedByUserID: stateUser.id, + submitReason: '1.1 new name', + }) ) // Now, find that contract and assert the history is what we expected @@ -360,12 +355,11 @@ describe('findRate', () => { await insertDraftContract(client, draftContractData) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'initial submit' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) ) // Add 3 rates 1, 2, 3 pointing to contract A @@ -467,12 +461,11 @@ describe('findRate', () => { }) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'Submitting A.1' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'Submitting A.1', + }) ) // Make a new Contract Revision, changing the connections should show up as a single new rev. @@ -499,12 +492,11 @@ describe('findRate', () => { }) ) must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'Submitting A.2' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'Submitting A.2', + }) ) // Now, find that contract and assert the history is what we expected diff --git a/services/app-api/src/postgres/contractAndRates/insertContract.ts b/services/app-api/src/postgres/contractAndRates/insertContract.ts index 23588b7e0c..c59fb8b14d 100644 --- a/services/app-api/src/postgres/contractAndRates/insertContract.ts +++ b/services/app-api/src/postgres/contractAndRates/insertContract.ts @@ -1,8 +1,4 @@ -import type { - PrismaClient, - SubmissionType, - ContractType as PrismaContractType, -} from '@prisma/client' +import type { PrismaClient } from '@prisma/client' import type { ContractType, ContractFormDataType, @@ -13,10 +9,10 @@ import { includeFullContract } from './prismaSubmittedContractHelpers' type InsertContractArgsType = Partial & { // Certain fields are required on insert contract only stateCode: string - programIDs: string[] - submissionType: SubmissionType - submissionDescription: string - contractType: PrismaContractType + programIDs: ContractFormDataType['programIDs'] + submissionType: ContractFormDataType['submissionType'] + submissionDescription: ContractFormDataType['submissionDescription'] + contractType: ContractFormDataType['contractType'] } // creates a new contract, with a new revision diff --git a/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts index 82dfcc76e8..a2a4be7a5b 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts @@ -5,6 +5,18 @@ import { includeRateFormData, } from './prismaSharedContractRateHelpers' +const includeFirstSubmittedRateRev = { + revisions: { + where: { + submitInfoID: { not: null }, + }, + take: 1, + orderBy: { + createdAt: 'desc', + }, + }, +} satisfies Prisma.RateTableInclude + // includeFullRate is the prisma includes block for a complete Rate const includeFullRate = { revisions: { @@ -42,6 +54,6 @@ type RateTableFullPayload = Prisma.RateTableGetPayload<{ type RateRevisionTableWithContracts = RateTableFullPayload['revisions'][0] -export { includeFullRate } +export { includeFullRate, includeFirstSubmittedRateRev } export type { RateTableFullPayload, RateRevisionTableWithContracts } diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.test.ts b/services/app-api/src/postgres/contractAndRates/submitContract.test.ts index 30f731a0a1..739bb126e9 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.test.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.test.ts @@ -24,12 +24,11 @@ describe('submitContract', () => { }) // submitting before there's a draft should be an error - const submitError = await submitContract( - client, - '1111', - '1111', - 'failed submit' - ) + const submitError = await submitContract(client, { + contractID: '1111', + submittedByUserID: '1111', + submitReason: 'failed submit', + }) expect(submitError).toBeInstanceOf(NotFoundError) // create a draft contract @@ -41,12 +40,11 @@ describe('submitContract', () => { ) // submit the draft contract const result = must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'initial submit' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) ) expect(result.revisions[0].submitInfo?.updatedReason).toBe( 'initial submit' @@ -66,12 +64,11 @@ describe('submitContract', () => { }) ) - const resubmitStoreError = await submitContract( - client, - contractA.id, - stateUser.id, - 'initial submit' - ) + const resubmitStoreError = await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) // resubmitting should be a store error expect(resubmitStoreError).toBeInstanceOf(NotFoundError) @@ -111,12 +108,11 @@ describe('submitContract', () => { // submit the first draft contract const submittedContractA = must( - await submitContract( - client, - contractA.id, - stateUser.id, - 'initial submit' - ) + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) ) // submit the first draft rate @@ -163,12 +159,11 @@ describe('submitContract', () => { // submit the second draft contract must( - await submitContract( - client, - contractASecondRevision.id, - stateUser.id, - 'second submit' - ) + await submitContract(client, { + contractID: contractASecondRevision.id, + submittedByUserID: stateUser.id, + submitReason: 'second submit', + }) ) /* now that the second contract revision has been submitted, the first contract revision should be invalidated. diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index b2630c38b6..bf137a2356 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -2,22 +2,28 @@ import type { PrismaClient } from '@prisma/client' import type { ContractType } from '../../domain-models/contractAndRates' import { findContractWithHistory } from './findContractWithHistory' import { NotFoundError } from '../storeError' +import type { UpdateInfoType } from '../../domain-models' +import { includeFirstSubmittedRateRev } from './prismaSubmittedRateHelpers' +type SubmitContractArgsType = { + contractID: string + submittedByUserID: UpdateInfoType['updatedBy'] + submitReason: UpdateInfoType['updatedReason'] +} // Update the given revision -// * invalidate relationships of previous revision -// * set the ActionInfo +// * invalidate relationships of previous revision by marking as outdated +// * set the UpdateInfo async function submitContract( client: PrismaClient, - contractID: string, - submittedByUserID: string, - submitReason: string + args: SubmitContractArgsType ): Promise { - const groupTime = new Date() + const { contractID, submittedByUserID, submitReason } = args + const currentDateTime = new Date() try { + // Find current contract revision with associated rates + // query only the submitted revisions on the associated rates return await client.$transaction(async (tx) => { - // Given all the Rates associated with this draft, find the most recent submitted - // rateRevision to attach to this contract on submit. const currentRev = await tx.contractRevisionTable.findFirst({ where: { contractID: contractID, @@ -25,17 +31,7 @@ async function submitContract( }, include: { draftRates: { - include: { - revisions: { - where: { - submitInfoID: { not: null }, - }, - take: 1, - orderBy: { - createdAt: 'desc', - }, - }, - }, + include: includeFirstSubmittedRateRev, }, }, }) @@ -46,17 +42,19 @@ async function submitContract( return new NotFoundError(err) } - const submittedRateRevisions = currentRev.draftRates.map( - (c) => c.revisions[0] + // Given associated rates, confirm rates valid by submitted by checking for revisions + // If rates have no revisions, we know it is invalid and can throw error + const associatedRateRevisionIDs = currentRev.draftRates.map( + (c) => c.revisions[0]?.id ) - - if (submittedRateRevisions.some((rev) => rev === undefined)) { - console.error( - 'Attempted to submit a contract related to a rate that has not been submitted' - ) - return new Error( - 'Attempted to submit a contract related to a rate that has not been submitted' - ) + const invalidRateRevisions = associatedRateRevisionIDs.find( + (rev) => rev === undefined + ) + if (invalidRateRevisions) { + const message = + 'Attempted to submit a contract related to a rate that has not been submitted.' + console.error(message) + return new Error(message) } const updated = await tx.contractRevisionTable.update({ @@ -66,16 +64,16 @@ async function submitContract( data: { submitInfo: { create: { - updatedAt: groupTime, + updatedAt: currentDateTime, updatedByID: submittedByUserID, updatedReason: submitReason, }, }, rateRevisions: { createMany: { - data: submittedRateRevisions.map((rev) => ({ - rateRevisionID: rev.id, - validAfter: groupTime, + data: associatedRateRevisionIDs.map((id) => ({ + rateRevisionID: id, + validAfter: currentDateTime, })), }, }, @@ -90,7 +88,7 @@ async function submitContract( }) // oldRev is the previously submitted revision of this contract (the one just superseded by the update) - // get the previous revision, to invalidate all relationships and add any removed entries to the join table. + // on an initial submission, there won't be an oldRev const oldRev = await tx.contractRevisionTable.findFirst({ where: { contractID: updated.contractID, @@ -110,11 +108,10 @@ async function submitContract( }, }) - // on an initial submission, there won't be an oldRev - // validUntil: null means it's current. we invalidate the joins on the old revision by giving it a validUntil value + // Take oldRev, invalidate all relationships and add any removed entries to the join table. if (oldRev) { - // if any of the old rev's Rates aren't in the new Rates, add an entry - // entry is for a previous rate to this new contractRev. + // If any of the old rev's Rates aren't in the new Rates, add an entry in revisions join table + // isRemoval field shows that this is a previous rate associated with this contract that is now removed const oldRateRevs = oldRev.rateRevisions .filter((rrevjoin) => !rrevjoin.validUntil) .map((rrevjoin) => rrevjoin.rateRevision) @@ -130,21 +127,22 @@ async function submitContract( data: removedRateRevs.map((rrev) => ({ contractRevisionID: updated.id, rateRevisionID: rrev.id, - validAfter: groupTime, - validUntil: groupTime, + validAfter: currentDateTime, + validUntil: currentDateTime, isRemoval: true, })), }) } - // invalidate all revisions associated with the previous rev + // Invalidate all revisions associated with the previous rev by updating validUntil + // these revisions are considered outdated going forward await tx.rateRevisionsOnContractRevisionsTable.updateMany({ where: { contractRevisionID: oldRev.id, validUntil: null, }, data: { - validUntil: groupTime, + validUntil: currentDateTime, }, }) } @@ -152,7 +150,7 @@ async function submitContract( return await findContractWithHistory(tx, contractID) }) } catch (err) { - console.error('SUBMIT PRISMA CONTRACT ERR', err) + console.error('Prisma error submitting contract', err) return err } } diff --git a/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts b/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts index 06410ea719..2e5ead04d9 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts @@ -59,8 +59,7 @@ describe('unlockContract', () => { // Connect draft contract to submitted rate must( - await updateDraftContract( - client,{ + await updateDraftContract(client, { contractID: contract.id, formData: { submissionType: 'CONTRACT_AND_RATES', @@ -70,8 +69,8 @@ describe('unlockContract', () => { populationCovered: 'MEDICAID', riskBasedContract: false, }, - rateIDs: [rate.id]} - ) + rateIDs: [rate.id], + }) ) const fullDraftContract = must( @@ -165,9 +164,8 @@ describe('unlockContract', () => { // Connect draft contract to submitted rate must( - await updateDraftContract( - client, - {contractID: contract.id, + await updateDraftContract(client, { + contractID: contract.id, formData: { submissionType: 'CONTRACT_AND_RATES', submissionDescription: 'Connecting rate', @@ -176,18 +174,17 @@ describe('unlockContract', () => { populationCovered: 'MEDICAID', riskBasedContract: false, }, - rateIDs: [rate.id]} - ) + rateIDs: [rate.id], + }) ) // Submit contract const submittedContract = must( - await submitContract( - client, - contract.id, - stateUser.id, - 'Initial Submit' - ) + await submitContract(client, { + contractID: contract.id, + submittedByUserID: stateUser.id, + submitReason: 'Initial Submit', + }) ) // Latest revision is the last index const latestContractRev = submittedContract.revisions[0] @@ -264,9 +261,7 @@ describe('unlockContract', () => { // Connect draft contract to draft rate must( - await updateDraftContract( - client, - { + await updateDraftContract(client, { contractID: contract.id, formData: { submissionType: 'CONTRACT_AND_RATES', @@ -276,8 +271,8 @@ describe('unlockContract', () => { populationCovered: 'MEDICAID', riskBasedContract: false, }, - rateIDs: [rate.id]} - ) + rateIDs: [rate.id], + }) ) // Submit rate @@ -287,12 +282,11 @@ describe('unlockContract', () => { // Submit contract const submittedContract = must( - await submitContract( - client, - contract.id, - stateUser.id, - 'Submit contract 1.0' - ) + await submitContract(client, { + contractID: contract.id, + submittedByUserID: stateUser.id, + submitReason: 'Submit contract 1.0', + }) ) const latestContractRev = submittedContract.revisions[0] @@ -310,9 +304,8 @@ describe('unlockContract', () => { ) ) must( - await updateDraftContract( - client, - {contractID: contract.id, + await updateDraftContract(client, { + contractID: contract.id, formData: { submissionType: 'CONTRACT_AND_RATES', submissionDescription: 'contract 2.0', @@ -321,16 +314,15 @@ describe('unlockContract', () => { populationCovered: 'MEDICAID', riskBasedContract: false, }, - rateIDs: [rate.id]} - ) + rateIDs: [rate.id], + }) ) const resubmittedContract = must( - await submitContract( - client, - contract.id, - stateUser.id, - 'Submit contract 2.0' - ) + await submitContract(client, { + contractID: contract.id, + submittedByUserID: stateUser.id, + submitReason: 'Submit contract 2.0', + }) ) const latestResubmittedRev = resubmittedContract.revisions[0] @@ -370,10 +362,8 @@ describe('unlockContract', () => { // Connect draft contract to submitted rate must( - await updateDraftContract( - client, - { - contractID: contract.id, + await updateDraftContract(client, { + contractID: contract.id, formData: { submissionType: 'CONTRACT_AND_RATES', submissionDescription: 'contract 1.0', @@ -382,17 +372,16 @@ describe('unlockContract', () => { populationCovered: 'MEDICAID', riskBasedContract: false, }, - rateIDs: [rate.id]} - ) + rateIDs: [rate.id], + }) ) // Submit contract - const submittedContract = await submitContract( - client, - contract.id, - stateUser.id, - 'Submit contract 1.0' - ) + const submittedContract = await submitContract(client, { + contractID: contract.id, + submittedByUserID: stateUser.id, + submitReason: 'Submit contract 1.0', + }) expect(submittedContract).toBeInstanceOf(Error) }) it('errors when unlocking a draft contract or rate', async () => { diff --git a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts index e54a8b6ed3..8c9e966dbe 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts @@ -365,12 +365,11 @@ describe.each(flagValueTestParameters)( }, }) return must( - await submitContract( - client, - createdDraft.id, - stateUser.id, - 'Submission' - ) + await submitContract(client, { + contractID: createdDraft.id, + submittedByUserID: stateUser.id, + submitReason: 'Submission', + }) ) } else { return await createAndSubmitTestHealthPlanPackage(server) From b70b14a02f754eb9e2200219d896f350c83ea487 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Tue, 29 Aug 2023 16:14:40 -0500 Subject: [PATCH 02/23] submitRate working - update change history docs --- .images/change-history-feature-ui.png | Bin 0 -> 33380 bytes .../contract-rate-change-history.md | 24 +- .../findContractWithHistory.test.ts | 104 +++++++-- .../findRateWithHistory.test.ts | 67 ++++-- .../prismaSubmittedContractHelpers.ts | 14 +- .../contractAndRates/submitContract.test.ts | 22 +- .../contractAndRates/submitContract.ts | 26 +-- .../contractAndRates/submitRate.test.ts | 221 ++++++++++++++++++ .../postgres/contractAndRates/submitRate.ts | 84 ++++--- .../contractAndRates/unlockContract.test.ts | 30 ++- 10 files changed, 491 insertions(+), 101 deletions(-) create mode 100644 .images/change-history-feature-ui.png create mode 100644 services/app-api/src/postgres/contractAndRates/submitRate.test.ts diff --git a/.images/change-history-feature-ui.png b/.images/change-history-feature-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..50a818c66577b7dbaf6d7e8c754451f592e47444 GIT binary patch literal 33380 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 literal 0 HcmV?d00001 diff --git a/docs/technical-design/contract-rate-change-history.md b/docs/technical-design/contract-rate-change-history.md index 008b4710f9..66a3d23f8c 100644 --- a/docs/technical-design/contract-rate-change-history.md +++ b/docs/technical-design/contract-rate-change-history.md @@ -5,25 +5,37 @@ title: Calculating contract and Rate Change History ## Calculating contract and Rate Change History ## Overview -Change history is a feature of MC-Review where we track changes to the submission data over time and display this content to users. This document details how the change history is calculated for contract and rate data and which database fields are used. +Change history is the feature of MC-Review where the application stores full copies of changes to submission data over time. The data is displayed to users on the submission summary page. -[ADD IMAGE] + ![Change History on Submission Summary page](../../.images/change-history-feature-ui.png) + + This document details how the change history is calculated for contract and rate data and which database fields are used. ## Contraints - MC-Review must track actions on contract or a rate (submit, unlock, resubmit) alongside the full form data present at that that momment in the database. This data is used for CMS reporting and audit purposes. It is considered part of the system of system. - From a product requirement standpoint, MC-Review does not need to track changes on drafts (each edit a state makes to a draft data directly updates the original resource). ## Implementation -### A full copy of the submission data at a given point in time is called a revision +### The full copy of the submission data at a given point in time is called a revision. It is updated on submit. The change history audit log is a list of revisions sorted by data. This is currently stored in the `revisions` field on the Contract and Rate tables. A new revision is created each time a version of the form is submitted by states to CMS. -### There is important metadata associated with a revision to track user actions -The `unlockInfo` and `submitInfo` associated with that revision is important metadata. Specifically, revisions that have unlocked have unlock info data. If they do not have unlock info data, we can assume 1. that revision is the latest submitted version 2. that revision is the first submission associated with that contrac tor rate. + +## The link between contract and rates is versioned as well. It is updated on submit. + +It's possible to tell if a link has become outdated by refencing the `valid After` and `validUntil` fields on the join table between contract and rate revisions. The `validFrom` is set when a link is created (when a contract is submitted with a link to rates). At the point of creation, the `validUntil` is still null. + +Later, if the related contract is then unlocked and resubmitted with the linked rate removed, the `validUntil` will be set. The revision link is still kept in the change history. But the link itself will be considered outdated since the `validUntil` is in the past. + +*Dev Note* : If the `validUntil` is null we can assume the link between contract and rate is current. +### There is important metadata associated with a revision to track user actions. It is updated on submit and unlock. +The `unlockInfo` and `submitInfo` associated with that revision is important metadata. Specifically, only revisions that are unlocked or resubmitted have unlock info data. + +*Dev Note*: If a submission has undefined `unlockInfo``, we can assume two things 1. that revision is the latest submitted version 2. that revision is the first submission associated with that contract or rate. + TODO - [ ] add discussion `find*WithHistory` -- [ ] add discussion of `validAfter` and `validUntil` - [ ] add discussion `draftRevision` ## Related documentation diff --git a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts index 52b45e3d41..1a85526d25 100644 --- a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts @@ -66,7 +66,13 @@ describe('findContract', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate1.id, stateUser.id, 'Rate Submit')) + must( + await submitRate(client, { + rateID: rate1.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate Submit', + }) + ) const rate2 = must( await insertDraftRate(client, { @@ -81,7 +87,13 @@ describe('findContract', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate2.id, stateUser.id, 'RateSubmit 2')) + must( + await submitRate(client, { + rateID: rate2.id, + submittedByUserID: stateUser.id, + submitReason: 'RateSubmit 2', + }) + ) const rate3 = must( await insertDraftRate(client, { @@ -96,7 +108,13 @@ describe('findContract', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate3.id, stateUser.id, '3.0 create')) + must( + await submitRate(client, { + rateID: rate3.id, + submittedByUserID: stateUser.id, + submitReason: '3.0 create', + }) + ) // Now, find that contract and assert the history is what we expected const threeContract = must( @@ -123,7 +141,13 @@ describe('findContract', () => { contractIDs: [], }) ) - must(await submitRate(client, rate2.id, stateUser.id, '2.1 remove')) + must( + await submitRate(client, { + rateID: rate2.id, + submittedByUserID: stateUser.id, + submitReason: '2.1 remove', + }) + ) // Now, find that contract and assert the history is what we expected const twoContract = must( @@ -144,7 +168,13 @@ describe('findContract', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate1.id, stateUser.id, '1.1 new name')) + must( + await submitRate(client, { + rateID: rate1.id, + submittedByUserID: stateUser.id, + submitReason: '1.1 new name', + }) + ) // Now, find that contract and assert the history is what we expected const backAgainContract = must( @@ -372,7 +402,13 @@ describe('findContract', () => { } ) ) - must(await submitRate(client, rate1.id, stateUser.id, 'Rate Submit')) + must( + await submitRate(client, { + rateID: rate1.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate Submit', + }) + ) const rate2 = must( await insertDraftRate(client, { @@ -387,7 +423,13 @@ describe('findContract', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate2.id, stateUser.id, 'RateSubmit 2')) + must( + await submitRate(client, { + rateID: rate2.id, + submittedByUserID: stateUser.id, + submitReason: 'RateSubmit 2', + }) + ) const rate3 = must( await insertDraftRate(client, { @@ -402,7 +444,13 @@ describe('findContract', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate3.id, stateUser.id, '3.0 create')) + must( + await submitRate(client, { + rateID: rate3.id, + submittedByUserID: stateUser.id, + submitReason: '3.0 create', + }) + ) // remove the connection from rate 2 must( @@ -420,7 +468,13 @@ describe('findContract', () => { contractIDs: [], }) ) - must(await submitRate(client, rate2.id, stateUser.id, '2.1 remove')) + must( + await submitRate(client, { + rateID: rate2.id, + submittedByUserID: stateUser.id, + submitReason: '2.1 remove', + }) + ) // update rate 1 to have a new version, should make one new rev. must(await unlockRate(client, rate1.id, cmsUser.id, 'unlock for 1.1')) @@ -431,7 +485,13 @@ describe('findContract', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate1.id, stateUser.id, '1.1 new name')) + must( + await submitRate(client, { + rateID: rate1.id, + submittedByUserID: stateUser.id, + submitReason: '1.1 new name', + }) + ) // Make a new Contract Revision, should show up as a single new rev with all the old info must( @@ -580,7 +640,13 @@ describe('findContract', () => { contractIDs: [], }) ) - must(await submitRate(client, rate1.id, stateUser.id, 'Rate Submit')) + must( + await submitRate(client, { + rateID: rate1.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate Submit', + }) + ) const rate2 = must( await insertDraftRate(client, { @@ -595,7 +661,13 @@ describe('findContract', () => { contractIDs: [], }) ) - must(await submitRate(client, rate2.id, stateUser.id, 'Rate Submit 2')) + must( + await submitRate(client, { + rateID: rate2.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate Submit 2', + }) + ) // add a contract that has both of them. const draftContractData = createInsertContractData({ @@ -669,7 +741,13 @@ describe('findContract', () => { ).toEqual(['onepoint0', 'twopointone']) // Submit Rate 2.1 - must(await submitRate(client, rate2.id, stateUser.id, '2.1 update')) + must( + await submitRate(client, { + rateID: rate2.id, + submittedByUserID: stateUser.id, + submitReason: '2.1 update', + }) + ) // raft should still pull revision 2.1 out const draftPostRateSubmit = must( diff --git a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts index 628e7b2e7b..b9bd0d0a8c 100644 --- a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts @@ -44,12 +44,11 @@ describe('findRate', () => { }) const rateA = must(await insertDraftRate(client, draftRateData)) must( - await submitRate( - client, - rateA.id, - stateUser.id, - 'initial rate submit' - ) + await submitRate(client, { + rateID: rateA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial rate submit', + }) ) // Add 3 contracts 1, 2, 3 pointing to rate A @@ -197,7 +196,13 @@ describe('findRate', () => { // Make a new Contract Revision, should show up as a single new rev with all the old info must(await unlockRate(client, rateA.id, cmsUser.id, 'unlocking A.0')) - must(await submitRate(client, rateA.id, stateUser.id, 'Submitting A.1')) + must( + await submitRate(client, { + rateID: rateA.id, + submittedByUserID: stateUser.id, + submitReason: 'Submitting A.1', + }) + ) // Now, find that contract and assert the history is what we expected let testingRate = must(await findRateWithHistory(client, rateA.id)) @@ -217,7 +222,13 @@ describe('findRate', () => { contractIDs: [contract3.id], }) ) - must(await submitRate(client, rateA.id, stateUser.id, 'Submitting A.2')) + must( + await submitRate(client, { + rateID: rateA.id, + submittedByUserID: stateUser.id, + submitReason: 'Submitting A.2', + }) + ) // Now, find that contract and assert the history is what we expected testingRate = must(await findRateWithHistory(client, rateA.id)) @@ -376,7 +387,13 @@ describe('findRate', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate1.id, stateUser.id, 'Rate Submit')) + must( + await submitRate(client, { + rateID: rate1.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate Submit', + }) + ) const rate2 = must( await insertDraftRate(client, { @@ -391,7 +408,13 @@ describe('findRate', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate2.id, stateUser.id, 'RateSubmit 2')) + must( + await submitRate(client, { + rateID: rate2.id, + submittedByUserID: stateUser.id, + submitReason: 'RateSubmit 2', + }) + ) const rate3 = must( await insertDraftRate(client, { @@ -406,7 +429,13 @@ describe('findRate', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate3.id, stateUser.id, '3.0 create')) + must( + await submitRate(client, { + rateID: rate3.id, + submittedByUserID: stateUser.id, + submitReason: '3.0 create', + }) + ) // remove the connection from rate 2 must( @@ -424,7 +453,13 @@ describe('findRate', () => { contractIDs: [], }) ) - must(await submitRate(client, rate2.id, stateUser.id, '2.1 remove')) + must( + await submitRate(client, { + rateID: rate2.id, + submittedByUserID: stateUser.id, + submitReason: '2.1 remove', + }) + ) // update rate 1 to have a new version, should make one new rev. must(await unlockRate(client, rate1.id, cmsUser.id, 'unlock for 1.1')) @@ -435,7 +470,13 @@ describe('findRate', () => { contractIDs: [contractA.id], }) ) - must(await submitRate(client, rate1.id, stateUser.id, '1.1 new name')) + must( + await submitRate(client, { + rateID: rate1.id, + submittedByUserID: stateUser.id, + submitReason: '1.1 new name', + }) + ) // Make a new Contract Revision, should show up as a single new rev with all the old info must( diff --git a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts index fb7333994e..5faf5d410d 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts @@ -7,6 +7,18 @@ import { // Generated Types +const includeFirstSubmittedContractRev = { + revisions: { + where: { + submitInfoID: { not: null }, + }, + take: 1, + orderBy: { + createdAt: 'desc', + }, + }, +} satisfies Prisma.ContractTableInclude + // The include parameters for everything in a Contract. const includeFullContract = { revisions: { @@ -44,6 +56,6 @@ type ContractTableFullPayload = Prisma.ContractTableGetPayload<{ type ContractRevisionTableWithRates = ContractTableFullPayload['revisions'][0] -export { includeFullContract } +export { includeFullContract, includeFirstSubmittedContractRev } export type { ContractRevisionTableWithRates, ContractTableFullPayload } diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.test.ts b/services/app-api/src/postgres/contractAndRates/submitContract.test.ts index 739bb126e9..3093e96ad9 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.test.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.test.ts @@ -117,12 +117,11 @@ describe('submitContract', () => { // submit the first draft rate const rateA1 = must( - await submitRate( - client, - rateA.id, - stateUser.id, - 'initial rate submit' - ) + await submitRate(client, { + rateID: rateA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial rate submit', + }) ) // set up the relation between the submitted contract and the rate await client.rateRevisionsOnContractRevisionsTable.create({ @@ -215,12 +214,11 @@ describe('submitContract', () => { contractIDs: [contractA.id], }) ) - const result = await submitRate( - client, - rate1.id, - stateUser.id, - 'Rate Submit' - ) + const result = await submitRate(client, { + rateID: rate1.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate Submit', + }) if (!(result instanceof Error)) { throw new Error('must be an error') diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index bf137a2356..0680119800 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -21,8 +21,8 @@ async function submitContract( const currentDateTime = new Date() try { - // Find current contract revision with associated rates - // query only the submitted revisions on the associated rates + // Find current contract revision with related rates + // query only the submitted revisions on the related rates return await client.$transaction(async (tx) => { const currentRev = await tx.contractRevisionTable.findFirst({ where: { @@ -42,15 +42,15 @@ async function submitContract( return new NotFoundError(err) } - // Given associated rates, confirm rates valid by submitted by checking for revisions + // Given related rates, confirm rates valid by submitted by checking for revisions // If rates have no revisions, we know it is invalid and can throw error - const associatedRateRevisionIDs = currentRev.draftRates.map( - (c) => c.revisions[0]?.id + const relatedRateRevs = currentRev.draftRates.map( + (c) => c.revisions[0] ) - const invalidRateRevisions = associatedRateRevisionIDs.find( - (rev) => rev === undefined + const everyRelatedRateIsSubmitted = relatedRateRevs.every( + (rev) => rev !== undefined ) - if (invalidRateRevisions) { + if (!everyRelatedRateIsSubmitted) { const message = 'Attempted to submit a contract related to a rate that has not been submitted.' console.error(message) @@ -71,8 +71,8 @@ async function submitContract( }, rateRevisions: { createMany: { - data: associatedRateRevisionIDs.map((id) => ({ - rateRevisionID: id, + data: relatedRateRevs.map((rev) => ({ + rateRevisionID: rev.id, validAfter: currentDateTime, })), }, @@ -111,7 +111,7 @@ async function submitContract( // Take oldRev, invalidate all relationships and add any removed entries to the join table. if (oldRev) { // If any of the old rev's Rates aren't in the new Rates, add an entry in revisions join table - // isRemoval field shows that this is a previous rate associated with this contract that is now removed + // isRemoval field shows that this is a previous rate related with this contract that is now removed const oldRateRevs = oldRev.rateRevisions .filter((rrevjoin) => !rrevjoin.validUntil) .map((rrevjoin) => rrevjoin.rateRevision) @@ -134,8 +134,8 @@ async function submitContract( }) } - // Invalidate all revisions associated with the previous rev by updating validUntil - // these revisions are considered outdated going forward + // Invalidate old revision join table links by updating validUntil + // these links are considered outdated going forward await tx.rateRevisionsOnContractRevisionsTable.updateMany({ where: { contractRevisionID: oldRev.id, diff --git a/services/app-api/src/postgres/contractAndRates/submitRate.test.ts b/services/app-api/src/postgres/contractAndRates/submitRate.test.ts new file mode 100644 index 0000000000..a735f4c3da --- /dev/null +++ b/services/app-api/src/postgres/contractAndRates/submitRate.test.ts @@ -0,0 +1,221 @@ +import { sharedTestPrismaClient } from '../../testHelpers/storeHelpers' +import { v4 as uuidv4 } from 'uuid' +import { submitRate } from './submitRate' +import { NotFoundError } from '../storeError' +import { createInsertRateData } from '../../testHelpers/contractAndRates/rateHelpers' +import { createInsertContractData, must } from '../../testHelpers' +import { insertDraftRate } from './insertRate' +import { submitContract } from './submitContract' +import { insertDraftContract } from './insertContract' +import { updateDraftContract } from './updateDraftContract' + +describe('submitRate', () => { + it('creates a standalone rate submission from a draft', async () => { + const client = await sharedTestPrismaClient() + + const stateUser = await client.user.create({ + data: { + id: uuidv4(), + givenName: 'Aang', + familyName: 'Avatar', + email: 'aang@example.com', + role: 'STATE_USER', + stateCode: 'NM', + }, + }) + + // submitting before there's a draft should be an error + const submitError = await submitRate(client, { + rateID: '1111', + submittedByUserID: '1111', + submitReason: 'failed submit', + }) + expect(submitError).toBeInstanceOf(NotFoundError) + + // create a draft rate + const draftRateData = createInsertRateData({ + rateCertificationName: 'rate-cert-name', + }) + const rateA = must(await insertDraftRate(client, draftRateData)) + // submit the draft contract + const result = must( + await submitRate(client, { + rateID: rateA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) + ) + expect(result.revisions[0].submitInfo?.updatedReason).toBe( + 'initial submit' + ) + + //Expect rate form data to be what was inserted + expect(result.revisions[0]).toEqual( + expect.objectContaining({ + formData: expect.objectContaining({ + rateCertificationName: 'rate-cert-name', + }), + }) + ) + + const resubmitStoreError = await submitRate(client, { + rateID: rateA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) + + // resubmitting should be a store error + expect(resubmitStoreError).toBeInstanceOf(NotFoundError) + }) + + it('invalidates old revisions when new revisions are submitted', async () => { + const client = await sharedTestPrismaClient() + + const stateUser = must( + await client.user.create({ + data: { + id: uuidv4(), + givenName: 'Aang', + familyName: 'Avatar', + email: 'aang@example.com', + role: 'STATE_USER', + stateCode: 'NM', + }, + }) + ) + + // create a draft rate + const draftRateData = createInsertRateData({ + rateCertificationName: 'first rate ', + }) + const rateA = must(await insertDraftRate(client, draftRateData)) + + // create a draft contract + const contractA = must( + await insertDraftContract( + client, + createInsertContractData({ + submissionDescription: 'first contract', + }) + ) + ) + + // submit the first draft rate with no associated contracts + const submittedRateA = must( + await submitRate(client, { + rateID: rateA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial submit', + }) + ) + + // submit the contract + const contractA1 = must( + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submitReason: 'initial rate submit', + }) + ) + // set up the relation between the submitted contract and the rate + await client.rateRevisionsOnContractRevisionsTable.create({ + data: { + rateRevisionID: submittedRateA.revisions[0].id, + contractRevisionID: contractA1.revisions[0].id, + validAfter: new Date(), + }, + }) + + // create a second draft rate + const rateASecondRevision = must( + await client.rateTable.update({ + where: { + id: rateA.id, + }, + data: { + revisions: { + create: { + rateCertificationName: 'second contract revision', + }, + }, + }, + include: { + revisions: true, + }, + }) + ) + + // submit the second draft rate + must( + await submitRate(client, { + rateID: rateASecondRevision.id, + submittedByUserID: stateUser.id, + submitReason: 'second submit', + }) + ) + + /* now that the second rate revision has been submitted, the first rate revision should be invalidated. + Something is invalidated when it gets a validUntil value, which marks the time it stopped being valid */ + const invalidatedRevision = must( + await client.rateRevisionsOnContractRevisionsTable.findFirst({ + where: { + rateRevisionID: submittedRateA.revisions[0].id, + validUntil: { not: null }, + }, + }) + ) + + expect(invalidatedRevision).not.toBeNull() + }) + + it('handles concurrent drafts correctly', async () => { + const client = await sharedTestPrismaClient() + + const stateUser = await client.user.create({ + data: { + id: uuidv4(), + givenName: 'Aang', + familyName: 'Avatar', + email: 'aang@example.com', + role: 'STATE_USER', + stateCode: 'NM', + }, + }) + + const draftRateData = createInsertRateData({ + rateCertificationName: 'one contract', + }) + const rateA = must(await insertDraftRate(client, draftRateData)) + + // Attempt to submit a contract related to this draft rate + const contract1 = must( + await insertDraftContract(client, { + stateCode: 'MN', + submissionDescription: 'onepoint0', + contractType: 'BASE', + submissionType: 'CONTRACT_AND_RATES', + programIDs: ['test1'], + }) + ) + must( + await updateDraftContract(client, { + contractID: contract1.id, + formData: { submissionDescription: 'onepoint0' }, + rateIDs: [rateA.id], + }) + ) + + const result = await submitContract(client, { + contractID: contract1.id, + submittedByUserID: stateUser.id, + submitReason: 'Contract Submit', + }) + + if (!(result instanceof Error)) { + throw new Error('must be an error') + } + expect(result.message).toBe( + 'Attempted to submit a contract related to a rate that has not been submitted.' + ) + }) +}) diff --git a/services/app-api/src/postgres/contractAndRates/submitRate.ts b/services/app-api/src/postgres/contractAndRates/submitRate.ts index 9673412253..45ac23241c 100644 --- a/services/app-api/src/postgres/contractAndRates/submitRate.ts +++ b/services/app-api/src/postgres/contractAndRates/submitRate.ts @@ -1,59 +1,63 @@ +import { findRateWithHistory } from './findRateWithHistory' +import type { UpdateInfoType } from '../../domain-models' import type { PrismaClient } from '@prisma/client' import type { RateType } from '../../domain-models/contractAndRates' -import { findRateWithHistory } from './findRateWithHistory' +import { includeFirstSubmittedContractRev } from './prismaSubmittedContractHelpers' +import { NotFoundError } from '../storeError' +type SubmitRateArgsType = { + rateID: string + submittedByUserID: UpdateInfoType['updatedBy'] + submitReason: UpdateInfoType['updatedReason'] +} // Update the given revision // * invalidate relationships of previous revision -// * set the ActionInfo +// * set the UpdateInfo async function submitRate( client: PrismaClient, - rateID: string, - submittedByUserID: string, - submitReason: string -): Promise { - const groupTime = new Date() + args: SubmitRateArgsType +): Promise { + const currentDateTime = new Date() try { return await client.$transaction(async (tx) => { - // Given all the Contracts associated with this draft, find the most recent submitted - // contractRevision to attach to this rate on submit. + const { rateID, submittedByUserID, submitReason } = args + // Find current rate revision with related contract + // query only the submitted revisions on the associated contracts const currentRev = await tx.rateRevisionTable.findFirst({ where: { - rateID: rateID, + rateID, submitInfoID: null, }, include: { draftContracts: { - include: { - revisions: { - where: { - submitInfoID: { not: null }, - }, - take: 1, - orderBy: { - createdAt: 'desc', - }, - }, - }, + include: includeFirstSubmittedContractRev, }, }, }) if (!currentRev) { - console.error('No Unsubmitted Rate Rev!') - return new Error('cant find the current rev to submit') + const err = `PRISMA ERROR: Cannot find the current rev to submit with rate id: ${rateID}` + console.error(err) + return new NotFoundError(err) } - const freshContractRevs = currentRev.draftContracts.map( + // Given related contracts, confirm contracts valid by submitted by checking for revisions + // If related contracts have no initial revision, we know that link is invalid and can throw error + const relatedContractRevs = currentRev.draftContracts.map( (c) => c.revisions[0] ) - if (freshContractRevs.some((rev) => rev === undefined)) { - console.error( - 'Attempted to submit a rate related to a contract that has not been submitted' - ) - return new Error( + const everyRelatedContractIsSubmitted = relatedContractRevs.every( + (rev) => rev !== undefined + ) + + if (!everyRelatedContractIsSubmitted) { + const message = 'Attempted to submit a rate related to a contract that has not been submitted' - ) + + console.error(message) + + return new Error(message) } const updated = await tx.rateRevisionTable.update({ @@ -63,16 +67,16 @@ async function submitRate( data: { submitInfo: { create: { - updatedAt: groupTime, + updatedAt: currentDateTime, updatedByID: submittedByUserID, updatedReason: submitReason, }, }, contractRevisions: { createMany: { - data: freshContractRevs.map((rev) => ({ + data: relatedContractRevs.map((rev) => ({ contractRevisionID: rev.id, - validAfter: groupTime, + validAfter: currentDateTime, })), }, }, @@ -86,6 +90,8 @@ async function submitRate( }, }) + // oldRev is the previously submitted revision of this rate (the one just superseded by the update) + // on an initial submission, there won't be an oldRev const oldRev = await tx.rateRevisionTable.findFirst({ where: { rateID: updated.rateID, @@ -107,7 +113,7 @@ async function submitRate( // invalidate all joins on the old revision if (oldRev) { - // if any of the old rev's Rates aren't in the new Rates, add an entry + // if any of the old rev's related contracts aren't in the new Rates, add an entry for that removal const oldContractRevs = oldRev.contractRevisions .filter((crevjoin) => !crevjoin.validUntil) .map((crevjoin) => crevjoin.contractRevision) @@ -123,20 +129,22 @@ async function submitRate( data: removedContractRevs.map((crev) => ({ rateRevisionID: updated.id, contractRevisionID: crev.id, - validAfter: groupTime, - validUntil: groupTime, + validAfter: currentDateTime, + validUntil: currentDateTime, isRemoval: true, })), }) } + // Invalidate old revision join table links by updating validUntil + // these links are considered outdated going forward await tx.rateRevisionsOnContractRevisionsTable.updateMany({ where: { rateRevisionID: oldRev.id, validUntil: null, }, data: { - validUntil: groupTime, + validUntil: currentDateTime, }, }) } @@ -144,7 +152,7 @@ async function submitRate( return findRateWithHistory(tx, rateID) }) } catch (err) { - console.error('SUBMIT PRISMA CONTRACT ERR', err) + console.error('Prisma error submitting rate', err) return err } } diff --git a/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts b/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts index 2e5ead04d9..50cad29df7 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts @@ -54,7 +54,11 @@ describe('unlockContract', () => { // Submit Rate A const submittedRate = must( - await submitRate(client, rate.id, stateUser.id, 'Rate A 1.0 submit') + await submitRate(client, { + rateID: rate.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate A 1.0 submit', + }) ) // Connect draft contract to submitted rate @@ -99,7 +103,11 @@ describe('unlockContract', () => { ) const resubmittedRate = must( - await submitRate(client, rate.id, stateUser.id, 'Updated things') + await submitRate(client, { + rateID: rate.id, + submittedByUserID: stateUser.id, + submitReason: 'Updated things', + }) ) const fullDraftContractTwo = must( @@ -159,7 +167,11 @@ describe('unlockContract', () => { // Submit Rate A const submittedRate = must( - await submitRate(client, rate.id, stateUser.id, 'Rate 1.0 submit') + await submitRate(client, { + rateID: rate.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate 1.0 submit', + }) ) // Connect draft contract to submitted rate @@ -204,7 +216,11 @@ describe('unlockContract', () => { }) ) const resubmittedRate = must( - await submitRate(client, rate.id, stateUser.id, 'Rate resubmit') + await submitRate(client, { + rateID: rate.id, + submittedByUserID: stateUser.id, + submitReason: 'Rate resubmit', + }) ) // Expect rate to still be connected to submitted contract @@ -277,7 +293,11 @@ describe('unlockContract', () => { // Submit rate const submittedRate = must( - await submitRate(client, rate.id, stateUser.id, 'Submit rate 1.0') + await submitRate(client, { + rateID: rate.id, + submittedByUserID: stateUser.id, + submitReason: 'Submit rate 1.0', + }) ) // Submit contract From 02f72bfcd39224e77826a69b1a21fa8b258ec27b Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Fri, 1 Sep 2023 15:16:54 -0500 Subject: [PATCH 03/23] Tests passing --- .../contractAndRates/contractTypes.ts | 7 +- .../contractAndRates/convertContractToHPP.ts | 155 +++++++++++ .../contractAndRates/rateTypes.ts | 7 +- .../parseContractWithHistory.ts | 6 +- .../contractAndRates/parseRateWithHistory.ts | 10 +- .../prismaSharedContractRateHelpers.ts | 31 ++- .../prismaToDomainModel.test.ts | 114 ++++++-- .../src/resolvers/configureResolvers.ts | 3 +- .../submitHealthPlanPackage.ts | 260 +++++++++++------- 9 files changed, 456 insertions(+), 137 deletions(-) create mode 100644 services/app-api/src/domain-models/contractAndRates/convertContractToHPP.ts diff --git a/services/app-api/src/domain-models/contractAndRates/contractTypes.ts b/services/app-api/src/domain-models/contractAndRates/contractTypes.ts index cfd41a0861..e56e69f20b 100644 --- a/services/app-api/src/domain-models/contractAndRates/contractTypes.ts +++ b/services/app-api/src/domain-models/contractAndRates/contractTypes.ts @@ -6,7 +6,12 @@ import { contractRevisionWithRatesSchema } from './revisionTypes' // submissions are kept intact here across time const contractSchema = z.object({ id: z.string().uuid(), - status: z.union([z.literal('SUBMITTED'), z.literal('DRAFT')]), + status: z.union([ + z.literal('SUBMITTED'), + z.literal('DRAFT'), + z.literal('UNLOCKED'), + z.literal('RESUBMITTED'), + ]), stateCode: z.string(), stateNumber: z.number().min(1), // If this contract is in a DRAFT or UNLOCKED status, there will be a draftRevision diff --git a/services/app-api/src/domain-models/contractAndRates/convertContractToHPP.ts b/services/app-api/src/domain-models/contractAndRates/convertContractToHPP.ts new file mode 100644 index 0000000000..4ee5d28453 --- /dev/null +++ b/services/app-api/src/domain-models/contractAndRates/convertContractToHPP.ts @@ -0,0 +1,155 @@ +import type { + SubmissionDocument, + UnlockedHealthPlanFormDataType, +} from 'app-web/src/common-code/healthPlanFormDataType' +import type { + HealthPlanPackageType, + HealthPlanRevisionType, +} from '../HealthPlanPackageType' +import type { ContractType } from './contractTypes' +import { + toDomain, + toProtoBuffer, +} from 'app-web/src/common-code/proto/healthPlanFormDataProto' + +function convertContractToUnlockedHealthPlanPackage( + contract: ContractType +): HealthPlanPackageType | Error { + console.info('Attempting to convert contract to health plan package') + + // Since drafts come in separate on the Contract type, we push it onto the revisions before converting below + if (contract.draftRevision) { + contract.revisions.unshift(contract.draftRevision) + } + + const healthPlanRevisions = + convertContractRevisionToHealthPlanRevision(contract) + + if (healthPlanRevisions instanceof Error) { + return healthPlanRevisions + } + + return { + id: contract.id, + stateCode: contract.stateCode, + revisions: healthPlanRevisions, + } +} + +function convertContractRevisionToHealthPlanRevision( + contract: ContractType +): HealthPlanRevisionType[] | Error { + if (contract.status !== 'DRAFT') { + return new Error( + `Contract with ID: ${contract.id} status is not "DRAFT". Cannot convert to unlocked health plan package` + ) + } + + let healthPlanRevisions: HealthPlanRevisionType[] | Error = [] + for (const contractRev of contract.revisions) { + const unlockedHealthPlanFormData: UnlockedHealthPlanFormDataType = { + id: contractRev.id, + createdAt: contractRev.createdAt, + updatedAt: contractRev.updatedAt, + status: contract.status, + stateCode: contract.stateCode, + stateNumber: contract.stateNumber, + programIDs: contractRev.formData.programIDs, + populationCovered: contractRev.formData.populationCovered, + submissionType: contractRev.formData.submissionType, + riskBasedContract: contractRev.formData.riskBasedContract, + submissionDescription: contractRev.formData.submissionDescription, + stateContacts: contractRev.formData.stateContacts, + addtlActuaryCommunicationPreference: undefined, + addtlActuaryContacts: [], + documents: contractRev.formData.supportingDocuments.map((doc) => ({ + ...doc, + documentCategories: ['CONTRACT_RELATED'], + })) as SubmissionDocument[], + contractType: contractRev.formData.contractType, + contractExecutionStatus: + contractRev.formData.contractExecutionStatus, + contractDocuments: contractRev.formData.contractDocuments.map( + (doc) => ({ + ...doc, + documentCategories: ['CONTRACT'], + }) + ) as SubmissionDocument[], + contractDateStart: contractRev.formData.contractDateStart, + contractDateEnd: contractRev.formData.contractDateEnd, + managedCareEntities: contractRev.formData.managedCareEntities, + federalAuthorities: contractRev.formData.federalAuthorities, + contractAmendmentInfo: { + modifiedProvisions: { + inLieuServicesAndSettings: + contractRev.formData.inLieuServicesAndSettings, + modifiedBenefitsProvided: + contractRev.formData.modifiedBenefitsProvided, + modifiedGeoAreaServed: + contractRev.formData.modifiedGeoAreaServed, + modifiedMedicaidBeneficiaries: + contractRev.formData.modifiedMedicaidBeneficiaries, + modifiedRiskSharingStrategy: + contractRev.formData.modifiedRiskSharingStrategy, + modifiedIncentiveArrangements: + contractRev.formData.modifiedIncentiveArrangements, + modifiedWitholdAgreements: + contractRev.formData.modifiedWitholdAgreements, + modifiedStateDirectedPayments: + contractRev.formData.modifiedStateDirectedPayments, + modifiedPassThroughPayments: + contractRev.formData.modifiedPassThroughPayments, + modifiedPaymentsForMentalDiseaseInstitutions: + contractRev.formData + .modifiedPaymentsForMentalDiseaseInstitutions, + modifiedMedicalLossRatioStandards: + contractRev.formData.modifiedMedicalLossRatioStandards, + modifiedOtherFinancialPaymentIncentive: + contractRev.formData + .modifiedOtherFinancialPaymentIncentive, + modifiedEnrollmentProcess: + contractRev.formData.modifiedEnrollmentProcess, + modifiedGrevienceAndAppeal: + contractRev.formData.modifiedGrevienceAndAppeal, + modifiedNetworkAdequacyStandards: + contractRev.formData.modifiedNetworkAdequacyStandards, + modifiedLengthOfContract: + contractRev.formData.modifiedLengthOfContract, + modifiedNonRiskPaymentArrangements: + contractRev.formData.modifiedNonRiskPaymentArrangements, + }, + }, + rateInfos: [], + } + + const formDataProto = toProtoBuffer(unlockedHealthPlanFormData) + + // check that we can encode then decode with no issues + const domainData = toDomain(formDataProto) + + // If any revision has en error in decoding we break the loop and return an error + if (domainData instanceof Error) { + healthPlanRevisions = new Error( + `Could not convert contract revision with ID: ${contractRev.id} to health plan package revision: ${domainData}` + ) + break + } + + const healthPlanRevision: HealthPlanRevisionType = { + id: contractRev.id, + unlockInfo: contractRev.unlockInfo, + submitInfo: contractRev.submitInfo, + createdAt: contractRev.createdAt, + formDataProto, + } + + healthPlanRevisions.push(healthPlanRevision) + } + + return healthPlanRevisions +} + +export { + convertContractRevisionToHealthPlanRevision, + convertContractToUnlockedHealthPlanPackage, +} diff --git a/services/app-api/src/domain-models/contractAndRates/rateTypes.ts b/services/app-api/src/domain-models/contractAndRates/rateTypes.ts index b2446051f4..a11cdf83ac 100644 --- a/services/app-api/src/domain-models/contractAndRates/rateTypes.ts +++ b/services/app-api/src/domain-models/contractAndRates/rateTypes.ts @@ -6,7 +6,12 @@ import { const rateSchema = z.object({ id: z.string().uuid(), - status: z.union([z.literal('SUBMITTED'), z.literal('DRAFT')]), + status: z.union([ + z.literal('SUBMITTED'), + z.literal('DRAFT'), + z.literal('UNLOCKED'), + z.literal('RESUBMITTED'), + ]), stateCode: z.string(), stateNumber: z.number().min(1), // If this rate is in a DRAFT or UNLOCKED status, there will be a draftRevision diff --git a/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts b/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts index 083665e2f9..ec73eb0c4a 100644 --- a/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts @@ -14,7 +14,7 @@ import { contractFormDataToDomainModel, convertUpdateInfoToDomainModel, ratesRevisionsToDomainModel, - getContractStatus, + getContractRateStatus, } from './prismaSharedContractRateHelpers' import type { ContractTableFullPayload } from './prismaSubmittedContractHelpers' @@ -177,11 +177,11 @@ function contractWithHistoryToDomainModel( return { id: contract.id, - status: getContractStatus(contract.revisions), + status: getContractRateStatus(contract.revisions), stateCode: contract.stateCode, stateNumber: contract.stateNumber, draftRevision, - revisions + revisions, } } diff --git a/services/app-api/src/postgres/contractAndRates/parseRateWithHistory.ts b/services/app-api/src/postgres/contractAndRates/parseRateWithHistory.ts index 6942a83424..0e5962535e 100644 --- a/services/app-api/src/postgres/contractAndRates/parseRateWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/parseRateWithHistory.ts @@ -1,7 +1,7 @@ import type { RateRevisionWithContractsType, RateType, - RateRevisionType + RateRevisionType, } from '../../domain-models/contractAndRates' import { rateSchema } from '../../domain-models/contractAndRates' import { contractRevisionsToDomainModels } from './parseContractWithHistory' @@ -13,7 +13,7 @@ import type { } from './prismaSharedContractRateHelpers' import { convertUpdateInfoToDomainModel, - getContractStatus, + getContractRateStatus, rateFormDataToDomainModel, } from './prismaSharedContractRateHelpers' import type { RateTableFullPayload } from './prismaSubmittedRateHelpers' @@ -88,7 +88,7 @@ function rateRevisionsToDomainModels( function rateWithHistoryToDomainModel( rate: RateTableFullPayload ): RateType | Error { - // so you get all the rate revisions. each one has a bunch of contracts + // so you get all the rate revisions. each one has a bunch of contracts // each set of contracts gets its own "revision" in the return list // further rateRevs naturally are their own "revision" @@ -168,11 +168,11 @@ function rateWithHistoryToDomainModel( return { id: rate.id, - status: getContractStatus(rate.revisions), + status: getContractRateStatus(rateRevisions), stateCode: rate.stateCode, stateNumber: rate.stateNumber, draftRevision, - revisions + revisions, } } export { diff --git a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts index 83c71b8d1d..ef216b7c86 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts @@ -1,4 +1,4 @@ -import type { Prisma, UpdateInfoTable } from '@prisma/client' +import type { Prisma } from '@prisma/client' import type { DocumentCategoryType } from 'app-web/src/common-code/healthPlanFormDataType' import type { ContractFormDataType, @@ -35,19 +35,28 @@ function convertUpdateInfoToDomainModel( } // ----- - -function getContractStatus( - revision: { - createdAt: Date - submitInfo: UpdateInfoTable | null - }[] +function getContractRateStatus( + revisions: + | ContractRevisionTableWithFormData[] + | RateRevisionTableWithFormData[] ): ContractStatusType { // need to order revisions from latest to earliest - const latestToEarliestRev = revision.sort( + const revs = revisions.sort( (revA, revB) => revB.createdAt.getTime() - revA.createdAt.getTime() ) - const latestRevision = latestToEarliestRev[0] - return latestRevision?.submitInfo ? 'SUBMITTED' : 'DRAFT' + const latestRevision = revs[0] + // submitted - one revision with submission status + if (revs.length === 1 && latestRevision.submitInfo) { + return 'SUBMITTED' + } else if (revs.length > 1) { + // unlocked - multiple revs, latest revision has unlocked status and no submitted status + // resubmitted - multiple revs, latest revision has submitted status + if (latestRevision.submitInfo) { + return 'RESUBMITTED' + } + return 'UNLOCKED' + } + return 'DRAFT' } // ------ @@ -248,7 +257,7 @@ export { includeUpdateInfo, includeContractFormData, includeRateFormData, - getContractStatus, + getContractRateStatus, convertUpdateInfoToDomainModel, contractFormDataToDomainModel, rateFormDataToDomainModel, diff --git a/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts b/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts index 251567c8e2..a38b7ed14e 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts @@ -1,10 +1,10 @@ import { v4 as uuidv4 } from 'uuid' import { createContractRevision } from '../../testHelpers' import { + getContractRateStatus, contractFormDataToDomainModel, - getContractStatus, } from './prismaSharedContractRateHelpers' -import type { ContractRevisionTableWithRates } from './prismaSubmittedContractHelpers' +import type { ContractRevisionTableWithFormData } from './prismaSharedContractRateHelpers' describe('prismaToDomainModel', () => { describe('contractFormDataToDomainModel', () => { @@ -40,21 +40,55 @@ describe('prismaToDomainModel', () => { }) }) - describe('getContractStatus', () => { + describe('getContractRateStatus', () => { + // Using type coercion in these tests rather than creating revisions + // we just care about unit testing different variations of submitInfo, updateInfo, and createdAt const contractWithUnorderedRevs: { - revision: Pick< - ContractRevisionTableWithRates, - 'createdAt' | 'submitInfo' - >[] + revision: ContractRevisionTableWithFormData[] testDescription: string - expectedResult: 'SUBMITTED' | 'DRAFT' + expectedResult: 'SUBMITTED' | 'DRAFT' | 'UNLOCKED' | 'RESUBMITTED' }[] = [ { revision: [ { - createdAt: new Date(21, 2, 1), - submitInfo: null, - }, + createdAt: new Date(21, 3, 1), + submitInfo: { + id: uuidv4(), + updatedAt: new Date(), + updatedByID: 'someone', + updatedReason: 'submit', + updatedBy: { + id: 'someone', + createdAt: new Date(), + updatedAt: new Date(), + givenName: 'Bob', + familyName: 'Law', + email: 'boblaw@example.com', + role: 'STATE_USER', + divisionAssignment: null, + stateCode: 'OH', + }, + }, + } as ContractRevisionTableWithFormData, + ], + testDescription: 'only one revision exists with a submit info', + expectedResult: 'SUBMITTED', + }, + { + revision: [ + { + createdAt: new Date(21, 3, 1), + } as ContractRevisionTableWithFormData, + ], + testDescription: + 'only one revision exists with not submit info', + expectedResult: 'DRAFT', + }, + { + revision: [ + { + createdAt: new Date(21, 4, 1), + } as ContractRevisionTableWithFormData, { createdAt: new Date(21, 3, 1), submitInfo: { @@ -74,14 +108,31 @@ describe('prismaToDomainModel', () => { stateCode: 'OH', }, }, - }, + } as ContractRevisionTableWithFormData, { createdAt: new Date(21, 1, 1), - submitInfo: null, - }, + submitInfo: { + id: uuidv4(), + updatedAt: new Date(), + updatedByID: 'someone', + updatedReason: 'submit', + updatedBy: { + id: 'someone', + createdAt: new Date(), + updatedAt: new Date(), + givenName: 'Bob', + familyName: 'Law', + email: 'boblaw@example.com', + role: 'STATE_USER', + divisionAssignment: null, + stateCode: 'OH', + }, + }, + } as ContractRevisionTableWithFormData, ], - testDescription: 'latest revision has been submitted', - expectedResult: 'SUBMITTED', + testDescription: + 'multiple reivsions and latest revision has not been submitted', + expectedResult: 'UNLOCKED', }, { revision: [ @@ -104,11 +155,27 @@ describe('prismaToDomainModel', () => { stateCode: 'OH', }, }, - }, + } as ContractRevisionTableWithFormData, { createdAt: new Date(21, 3, 1), - submitInfo: null, - }, + submitInfo: { + id: uuidv4(), + updatedAt: new Date(), + updatedByID: 'someone', + updatedReason: 'submit', + updatedBy: { + id: 'someone', + createdAt: new Date(), + updatedAt: new Date(), + givenName: 'Bob', + familyName: 'Law', + email: 'boblaw@example.com', + role: 'STATE_USER', + divisionAssignment: null, + stateCode: 'OH', + }, + }, + } as ContractRevisionTableWithFormData, { createdAt: new Date(21, 1, 1), submitInfo: { @@ -128,16 +195,17 @@ describe('prismaToDomainModel', () => { stateCode: 'OH', }, }, - }, + } as ContractRevisionTableWithFormData, ], - testDescription: 'latest revisions has not been submitted', - expectedResult: 'DRAFT', + testDescription: + 'multiple revisions and latest revision has been submitted', + expectedResult: 'RESUBMITTED', }, ] test.each(contractWithUnorderedRevs)( 'correctly gets contract status from unordered revisions: $testDescription', ({ revision, expectedResult }) => { - expect(getContractStatus(revision)).toEqual(expectedResult) + expect(getContractRateStatus(revision)).toEqual(expectedResult) } ) }) diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 277dd7fc30..13ce46419a 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -63,7 +63,8 @@ export function configureResolvers( submitHealthPlanPackage: submitHealthPlanPackageResolver( store, emailer, - emailParameterStore + emailParameterStore, + launchDarkly ), unlockHealthPlanPackage: unlockHealthPlanPackageResolver( store, diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index f488570461..159e7646ba 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -9,9 +9,12 @@ import { removeInvalidProvisionsAndAuthorities, isValidAndCurrentLockedHealthPlanFormData, hasValidSupportingDocumentCategories, + isContractOnly, + isCHIPOnly, } from '../../../../app-web/src/common-code/healthPlanFormDataType/healthPlanFormData' import type { UpdateInfoType, HealthPlanPackageType } from '../../domain-models' import { + convertContractToUnlockedHealthPlanPackage, isStateUser, packageStatus, packageSubmitters, @@ -20,7 +23,7 @@ import type { Emailer } from '../../emailer' import type { MutationResolvers, State } from '../../gen/gqlServer' import { logError, logSuccess } from '../../logger' import type { Store } from '../../postgres' -import { isStoreError } from '../../postgres' +import { NotFoundError, isStoreError } from '../../postgres' import { setResolverDetailsOnActiveSpan, setErrorAttributesOnActiveSpan, @@ -31,9 +34,10 @@ import type { EmailParameterStore } from '../../parameterStore' import { GraphQLError } from 'graphql' import type { - UnlockedHealthPlanFormDataType, + HealthPlanFormDataType, LockedHealthPlanFormDataType, } from '../../../../app-web/src/common-code/healthPlanFormDataType' +import type { LDService } from '../../launchDarkly/launchDarkly' export const SubmissionErrorCodes = ['INCOMPLETE', 'INVALID'] as const type SubmissionErrorCode = (typeof SubmissionErrorCodes)[number] // iterable union type @@ -67,7 +71,7 @@ export function isSubmissionError(err: unknown): err is SubmissionError { // This strategy (returning a different type from validation) is taken from the // "parse, don't validate" article: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ function submit( - draft: UnlockedHealthPlanFormDataType + draft: HealthPlanFormDataType ): LockedHealthPlanFormDataType | SubmissionError { const maybeStateSubmission: Record = { ...draft, @@ -121,17 +125,34 @@ function submit( } // submitHealthPlanPackageResolver is a state machine transition for HealthPlanPackage +// All the rate data that comes in in is revisions data. The id on the data is the revision id. +// export function submitHealthPlanPackageResolver( store: Store, emailer: Emailer, - emailParameterStore: EmailParameterStore + emailParameterStore: EmailParameterStore, + launchDarkly: LDService ): MutationResolvers['submitHealthPlanPackage'] { return async (_parent, { input }, context) => { + const ratesDatabaseRefactor = await launchDarkly.getFeatureFlag( + context, + 'rates-db-refactor' + ) const { user, span } = context const { submittedReason, pkgID } = input setResolverDetailsOnActiveSpan('submitHealthPlanPackage', user, span) span?.setAttribute('mcreview.package_id', pkgID) + let initialPackage: HealthPlanPackageType // before submit + let updatedPackage: HealthPlanPackageType // after submit + + //Set updateInfo default to initial submission + const updateInfo: UpdateInfoType = { + updatedAt: new Date(), + updatedBy: context.user.email, + updatedReason: 'Initial submission', + } + // This resolver is only callable by state users if (!isStateUser(user)) { logError( @@ -144,38 +165,75 @@ export function submitHealthPlanPackageResolver( ) throw new ForbiddenError('user not authorized to fetch state data') } + const stateFromCurrentUser: State['code'] = user.stateCode - // fetch from the store - const result = await store.findHealthPlanPackage(input.pkgID) + if (ratesDatabaseRefactor) { + // fetch from package and convert to HealthPlanPackage to match pattern for flag off + const contractWithHistory = await store.findContractWithHistory( + input.pkgID + ) - if (isStoreError(result)) { - const errMessage = `Issue finding a package of type ${result.code}. Message: ${result.message}` - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } + if (contractWithHistory instanceof Error) { + const errMessage = `Issue finding a contract with history with id ${input.pkgID}. Message: ${contractWithHistory.message}` + logError('fetchHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + + if (contractWithHistory instanceof NotFoundError) { + throw new GraphQLError(errMessage, { + extensions: { + code: 'NOT_FOUND', + cause: 'DB_ERROR', + }, + }) + } - if (result === undefined) { - const errMessage = `A draft must exist to be submitted: ${input.pkgID}` - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new UserInputError(errMessage, { - argumentName: 'pkgID', - }) - } + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + const maybeHealthPlanPackage = + convertContractToUnlockedHealthPlanPackage(contractWithHistory) + + if (maybeHealthPlanPackage instanceof Error) { + const message = 'TODO CONVERT ERROR' + console.error(message) + throw new Error(message) + } + initialPackage = maybeHealthPlanPackage + } else { + // fetch from package flag off - returns HealthPlanPackage + const originalPackage = await store.findHealthPlanPackage( + input.pkgID + ) - const planPackage: HealthPlanPackageType = result - const planPackageStatus = packageStatus(planPackage) - const currentRevision = planPackage.revisions[0] + if (isStoreError(originalPackage) || !originalPackage) { + if (!originalPackage) { + throw new GraphQLError('Issue finding package.', { + extensions: { + code: 'NOT_FOUND', + cause: 'DB_ERROR', + }, + }) + } + const errMessage = `Issue finding a package of type ${originalPackage.code}. Message: ${originalPackage.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } - // Authorization - const stateFromCurrentUser: State['code'] = user.stateCode - if (planPackage.stateCode !== stateFromCurrentUser) { + initialPackage = originalPackage + } + + // Validate user authorized to fetch state + if (initialPackage.stateCode !== stateFromCurrentUser) { logError( 'submitHealthPlanPackage', 'user not authorized to fetch data from a different state' @@ -189,26 +247,23 @@ export function submitHealthPlanPackageResolver( ) } - //Set updateInfo default to initial submission - const updateInfo: UpdateInfoType = { - updatedAt: new Date(), - updatedBy: context.user.email, - updatedReason: 'Initial submission', - } - - //If this is a resubmission set updateInfo updated reason to input. - if (planPackageStatus === 'UNLOCKED' && submittedReason) { + /* + Validate package is in right status to be submitted + - if currently UNLOCKED, also update submitInfo + */ + const initialPackageStatus = packageStatus(initialPackage) + if (initialPackageStatus === 'UNLOCKED' && submittedReason) { updateInfo.updatedReason = submittedReason - //Throw error if resubmitted without reason. We want to require an input reason for resubmission, but not for + // Throw error if resubmitted without reason. We want to require an input reason for resubmission, but not for // initial submission - } else if (planPackageStatus === 'UNLOCKED' && !submittedReason) { + } else if (initialPackageStatus === 'UNLOCKED' && !submittedReason) { const errMessage = 'Resubmission requires a reason' logError('submitHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new UserInputError(errMessage) } else if ( - planPackageStatus === 'RESUBMITTED' || - planPackageStatus === 'SUBMITTED' + initialPackageStatus === 'RESUBMITTED' || + initialPackageStatus === 'SUBMITTED' ) { const errMessage = `Attempted to submit an already submitted package.` logError('submitHealthPlanPackage', errMessage) @@ -220,10 +275,15 @@ export function submitHealthPlanPackageResolver( }) } - const draftResult = toDomain(currentRevision.formDataProto) - - if (draftResult instanceof Error) { - const errMessage = `Failed to decode draft proto ${draftResult}.` + /* + Unwrap HealthPlanPackage again to make further edits to data + */ + const currentRevision = initialPackage.revisions[0] + const currentFormData = toDomain( + initialPackage.revisions[0].formDataProto + ) + if (currentFormData instanceof Error) { + const errMessage = `Failed to decode draft proto ${currentFormData}.` logError('submitHealthPlanPackage', errMessage) throw new GraphQLError(errMessage, { extensions: { @@ -233,37 +293,31 @@ export function submitHealthPlanPackageResolver( }) } - if (draftResult.status === 'SUBMITTED') { - const errMessage = `Attempted to submit an already submitted package.` - logError('submitHealthPlanPackage', errMessage) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - }, - }) - } + /* + Clean form data and remove fields from edits on irrelevant logic branches + - CONTRACT_ONLY submission type should not contain any CONTRACT_AND_RATE rates data. + - CHIP_ONLY population covered should not contain any provision or authority relevant to other population. + - We delete at submission instead of update to preserve rates data in case user did not intend or would like to revert the submission type before submitting. + */ - // CONTRACT_ONLY submission should not contain any CONTRACT_AND_RATE rates data. We will delete if any valid - // rate data is in a CONTRACT_ONLY submission. This deletion is done at submission instead of update to preserve - // rates data in case user did not intend or would like to revert the submission type before submitting. if ( - draftResult.submissionType === 'CONTRACT_ONLY' && - hasAnyValidRateData(draftResult) + isContractOnly(currentFormData) && + hasAnyValidRateData(currentFormData) ) { - Object.assign(draftResult, removeRatesData(draftResult)) + Object.assign(initialPackage, removeRatesData(currentFormData)) } - - // CHIP submissions should not contain any provision or authority relevant to other populations - if (draftResult.populationCovered === 'CHIP') { + if (isCHIPOnly(currentFormData)) { Object.assign( - draftResult, - removeInvalidProvisionsAndAuthorities(draftResult) + initialPackage, + removeInvalidProvisionsAndAuthorities(currentFormData) ) } - // attempt to parse into a StateSubmission - const submissionResult = submit(draftResult) + /* + Final check of data before submit + - Parse to state submission + */ + const submissionResult = submit(currentFormData) if (isSubmissionError(submissionResult)) { const errMessage = submissionResult.message @@ -274,32 +328,54 @@ export function submitHealthPlanPackageResolver( }) } - const lockedFormData: LockedHealthPlanFormDataType = submissionResult + const lockedFormData = submissionResult - // Save the package! - const updateResult = await store.updateHealthPlanRevision( - planPackage.id, - currentRevision.id, - lockedFormData, - updateInfo - ) - if (isStoreError(updateResult)) { - const errMessage = `Issue updating a package of type ${updateResult.code}. Message: ${updateResult.message}` - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } + if (ratesDatabaseRefactor) { + // Save the submitted package + const updateResult = await store.updateHealthPlanRevision( + initialPackage.id, + currentRevision.id, + lockedFormData, + updateInfo + ) + if (isStoreError(updateResult)) { + const errMessage = `Issue updating a package of type ${updateResult.code}. Message: ${updateResult.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } - const updatedPackage: HealthPlanPackageType = updateResult + updatedPackage = updateResult + } else { + // Save the package! + const updateResult = await store.updateHealthPlanRevision( + initialPackage.id, + currentRevision.id, + lockedFormData, + updateInfo + ) + if (isStoreError(updateResult)) { + const errMessage = `Issue updating a package of type ${updateResult.code}. Message: ${updateResult.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + updatedPackage = updateResult + } // Send emails! const status = packageStatus(updatedPackage) - // Get state analysts emails from parameter store let stateAnalystsEmails = await emailParameterStore.getStateAnalystsEmails( From cc3cf2b1ce03985431db42035082a89f238cb5ac Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Fri, 1 Sep 2023 16:54:45 -0500 Subject: [PATCH 04/23] WIP - final commit - no more time to spend here --- .../contractAndRates/convertContractToHPP.ts | 155 -------------- .../convertContractWithRatesToHPP.ts | 198 ++++++++++++++++++ .../domain-models/contractAndRates/index.ts | 9 +- .../contractAndRates/updateInfoType.ts | 4 +- .../src/domain-models/healthPlanPackage.ts | 138 ------------ services/app-api/src/domain-models/index.ts | 6 +- .../prismaSharedContractRateHelpers.ts | 12 +- .../createHealthPlanPackage.ts | 4 +- .../fetchHealthPlanPackage.ts | 4 +- .../submitHealthPlanPackage.ts | 165 +++++++++------ .../updateHealthPlanFormData.ts | 4 +- 11 files changed, 320 insertions(+), 379 deletions(-) delete mode 100644 services/app-api/src/domain-models/contractAndRates/convertContractToHPP.ts create mode 100644 services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts diff --git a/services/app-api/src/domain-models/contractAndRates/convertContractToHPP.ts b/services/app-api/src/domain-models/contractAndRates/convertContractToHPP.ts deleted file mode 100644 index 4ee5d28453..0000000000 --- a/services/app-api/src/domain-models/contractAndRates/convertContractToHPP.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { - SubmissionDocument, - UnlockedHealthPlanFormDataType, -} from 'app-web/src/common-code/healthPlanFormDataType' -import type { - HealthPlanPackageType, - HealthPlanRevisionType, -} from '../HealthPlanPackageType' -import type { ContractType } from './contractTypes' -import { - toDomain, - toProtoBuffer, -} from 'app-web/src/common-code/proto/healthPlanFormDataProto' - -function convertContractToUnlockedHealthPlanPackage( - contract: ContractType -): HealthPlanPackageType | Error { - console.info('Attempting to convert contract to health plan package') - - // Since drafts come in separate on the Contract type, we push it onto the revisions before converting below - if (contract.draftRevision) { - contract.revisions.unshift(contract.draftRevision) - } - - const healthPlanRevisions = - convertContractRevisionToHealthPlanRevision(contract) - - if (healthPlanRevisions instanceof Error) { - return healthPlanRevisions - } - - return { - id: contract.id, - stateCode: contract.stateCode, - revisions: healthPlanRevisions, - } -} - -function convertContractRevisionToHealthPlanRevision( - contract: ContractType -): HealthPlanRevisionType[] | Error { - if (contract.status !== 'DRAFT') { - return new Error( - `Contract with ID: ${contract.id} status is not "DRAFT". Cannot convert to unlocked health plan package` - ) - } - - let healthPlanRevisions: HealthPlanRevisionType[] | Error = [] - for (const contractRev of contract.revisions) { - const unlockedHealthPlanFormData: UnlockedHealthPlanFormDataType = { - id: contractRev.id, - createdAt: contractRev.createdAt, - updatedAt: contractRev.updatedAt, - status: contract.status, - stateCode: contract.stateCode, - stateNumber: contract.stateNumber, - programIDs: contractRev.formData.programIDs, - populationCovered: contractRev.formData.populationCovered, - submissionType: contractRev.formData.submissionType, - riskBasedContract: contractRev.formData.riskBasedContract, - submissionDescription: contractRev.formData.submissionDescription, - stateContacts: contractRev.formData.stateContacts, - addtlActuaryCommunicationPreference: undefined, - addtlActuaryContacts: [], - documents: contractRev.formData.supportingDocuments.map((doc) => ({ - ...doc, - documentCategories: ['CONTRACT_RELATED'], - })) as SubmissionDocument[], - contractType: contractRev.formData.contractType, - contractExecutionStatus: - contractRev.formData.contractExecutionStatus, - contractDocuments: contractRev.formData.contractDocuments.map( - (doc) => ({ - ...doc, - documentCategories: ['CONTRACT'], - }) - ) as SubmissionDocument[], - contractDateStart: contractRev.formData.contractDateStart, - contractDateEnd: contractRev.formData.contractDateEnd, - managedCareEntities: contractRev.formData.managedCareEntities, - federalAuthorities: contractRev.formData.federalAuthorities, - contractAmendmentInfo: { - modifiedProvisions: { - inLieuServicesAndSettings: - contractRev.formData.inLieuServicesAndSettings, - modifiedBenefitsProvided: - contractRev.formData.modifiedBenefitsProvided, - modifiedGeoAreaServed: - contractRev.formData.modifiedGeoAreaServed, - modifiedMedicaidBeneficiaries: - contractRev.formData.modifiedMedicaidBeneficiaries, - modifiedRiskSharingStrategy: - contractRev.formData.modifiedRiskSharingStrategy, - modifiedIncentiveArrangements: - contractRev.formData.modifiedIncentiveArrangements, - modifiedWitholdAgreements: - contractRev.formData.modifiedWitholdAgreements, - modifiedStateDirectedPayments: - contractRev.formData.modifiedStateDirectedPayments, - modifiedPassThroughPayments: - contractRev.formData.modifiedPassThroughPayments, - modifiedPaymentsForMentalDiseaseInstitutions: - contractRev.formData - .modifiedPaymentsForMentalDiseaseInstitutions, - modifiedMedicalLossRatioStandards: - contractRev.formData.modifiedMedicalLossRatioStandards, - modifiedOtherFinancialPaymentIncentive: - contractRev.formData - .modifiedOtherFinancialPaymentIncentive, - modifiedEnrollmentProcess: - contractRev.formData.modifiedEnrollmentProcess, - modifiedGrevienceAndAppeal: - contractRev.formData.modifiedGrevienceAndAppeal, - modifiedNetworkAdequacyStandards: - contractRev.formData.modifiedNetworkAdequacyStandards, - modifiedLengthOfContract: - contractRev.formData.modifiedLengthOfContract, - modifiedNonRiskPaymentArrangements: - contractRev.formData.modifiedNonRiskPaymentArrangements, - }, - }, - rateInfos: [], - } - - const formDataProto = toProtoBuffer(unlockedHealthPlanFormData) - - // check that we can encode then decode with no issues - const domainData = toDomain(formDataProto) - - // If any revision has en error in decoding we break the loop and return an error - if (domainData instanceof Error) { - healthPlanRevisions = new Error( - `Could not convert contract revision with ID: ${contractRev.id} to health plan package revision: ${domainData}` - ) - break - } - - const healthPlanRevision: HealthPlanRevisionType = { - id: contractRev.id, - unlockInfo: contractRev.unlockInfo, - submitInfo: contractRev.submitInfo, - createdAt: contractRev.createdAt, - formDataProto, - } - - healthPlanRevisions.push(healthPlanRevision) - } - - return healthPlanRevisions -} - -export { - convertContractRevisionToHealthPlanRevision, - convertContractToUnlockedHealthPlanPackage, -} diff --git a/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts b/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts new file mode 100644 index 0000000000..12457cb753 --- /dev/null +++ b/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts @@ -0,0 +1,198 @@ +import type { + ActuaryContact, + RateInfoType, + SubmissionDocument, + UnlockedHealthPlanFormDataType, +} from 'app-web/src/common-code/healthPlanFormDataType' +import type { + HealthPlanPackageType, + HealthPlanRevisionType, +} from '../HealthPlanPackageType' +import type { ContractType } from './contractTypes' +import { + toDomain, + toProtoBuffer, +} from 'app-web/src/common-code/proto/healthPlanFormDataProto' +import type { ContractRevisionWithRatesType } from './revisionTypes' + +function convertContractWithRatesToUnlockedHPP( + contract: ContractType +): HealthPlanPackageType | Error { + console.info('Attempting to convert contract to health plan package') + + // Since drafts come in separate on the Contract type, we push it onto the revisions before converting below + if (contract.draftRevision) { + contract.revisions.unshift(contract.draftRevision) + } + + const healthPlanRevisions = + convertContractWithRatesRevtoHPPRev(contract) + + if (healthPlanRevisions instanceof Error) { + return healthPlanRevisions + } + + return { + id: contract.id, + stateCode: contract.stateCode, + revisions: healthPlanRevisions, + } +} + +function convertContractWithRatesRevtoHPPRev( + contract: ContractType +): HealthPlanRevisionType[] | Error { + if (contract.status !== 'DRAFT') { + return new Error( + `Contract with ID: ${contract.id} status is not "DRAFT". Cannot convert to unlocked health plan package` + ) + } + + let healthPlanRevisions: HealthPlanRevisionType[] | Error = [] + for (const contractRev of contract.revisions) { + const unlockedHealthPlanFormData = convertContractWithRatesToFormData(contractRev, contract.stateCode, contract.stateNumber) + + const formDataProto = toProtoBuffer(unlockedHealthPlanFormData) + + // check that we can encode then decode with no issues + const domainData = toDomain(formDataProto) + + // If any revision has en error in decoding we break the loop and return an error + if (domainData instanceof Error) { + healthPlanRevisions = new Error( + `Could not convert contract revision with ID: ${contractRev.id} to health plan package revision: ${domainData}` + ) + break + } + + const healthPlanRevision: HealthPlanRevisionType = { + id: contractRev.id, + unlockInfo: contractRev.unlockInfo, + submitInfo: contractRev.submitInfo, + createdAt: contractRev.createdAt, + formDataProto, + } + + healthPlanRevisions.push(healthPlanRevision) + } + + return healthPlanRevisions +} + +// TODO: Clean up parameters into args and improve types to make things more strict +const convertContractWithRatesToFormData = (contractRev: ContractRevisionWithRatesType, stateCode: string, stateNumber: number): UnlockedHealthPlanFormDataType => { + const rateInfos: RateInfoType[] = contractRev.rateRevisions.map((rateRev) => { + const { rateType, rateCapitationType, rateCertificationName, rateDateCertified, rateDateEnd, rateDateStart, rateDocuments = [], supportingDocuments = [], rateProgramIDs, packagesWithSharedRateCerts, certifyingActuaryContacts = [], addtlActuaryContacts = [], amendmentEffectiveDateEnd, amendmentEffectiveDateStart, actuaryCommunicationPreference } = rateRev.formData + return { + id: rateRev.id, // form data ids are always revision ID + rateType, + rateCapitationType, + rateDocuments: rateDocuments.map( + (doc) => ({ + ...doc, + documentCategories: ['RATES'], + }) + ) as SubmissionDocument[], + supportingDocuments: supportingDocuments.map( + (doc) => ({ + ...doc, + documentCategories: ['RATES_RELATED'], + }) + ) as SubmissionDocument[], + rateAmendmentInfo: { + effectiveDateEnd: amendmentEffectiveDateEnd, + effectiveDateStart: amendmentEffectiveDateStart + }, + rateDateStart, + rateDateEnd, + rateDateCertified, + rateProgramIDs, + rateCertificationName, + actuaryContacts: [...certifyingActuaryContacts, addtlActuaryContacts] as ActuaryContact[], + actuaryCommunicationPreference, + packagesWithSharedRateCerts + } + }) + const unlockedHealthPlanFormData: UnlockedHealthPlanFormDataType = { + id: contractRev.id, + createdAt: contractRev.createdAt, + updatedAt: contractRev.updatedAt, + status: 'DRAFT', + stateCode: stateCode, + stateNumber: stateNumber, + programIDs: contractRev.formData.programIDs, + populationCovered: contractRev.formData.populationCovered, + submissionType: contractRev.formData.submissionType, + riskBasedContract: contractRev.formData.riskBasedContract, + submissionDescription: contractRev.formData.submissionDescription, + stateContacts: contractRev.formData.stateContacts, + addtlActuaryCommunicationPreference: undefined, + addtlActuaryContacts: [], + documents: contractRev.formData.supportingDocuments.map((doc) => ({ + ...doc, + documentCategories: ['CONTRACT_RELATED'], + })) as SubmissionDocument[], + contractType: contractRev.formData.contractType, + contractExecutionStatus: + contractRev.formData.contractExecutionStatus, + contractDocuments: contractRev.formData.contractDocuments.map( + (doc) => ({ + ...doc, + documentCategories: ['CONTRACT'], + }) + ) as SubmissionDocument[], + contractDateStart: contractRev.formData.contractDateStart, + contractDateEnd: contractRev.formData.contractDateEnd, + managedCareEntities: contractRev.formData.managedCareEntities, + federalAuthorities: contractRev.formData.federalAuthorities, + contractAmendmentInfo: { + modifiedProvisions: { + inLieuServicesAndSettings: + contractRev.formData.inLieuServicesAndSettings, + modifiedBenefitsProvided: + contractRev.formData.modifiedBenefitsProvided, + modifiedGeoAreaServed: + contractRev.formData.modifiedGeoAreaServed, + modifiedMedicaidBeneficiaries: + contractRev.formData.modifiedMedicaidBeneficiaries, + modifiedRiskSharingStrategy: + contractRev.formData.modifiedRiskSharingStrategy, + modifiedIncentiveArrangements: + contractRev.formData.modifiedIncentiveArrangements, + modifiedWitholdAgreements: + contractRev.formData.modifiedWitholdAgreements, + modifiedStateDirectedPayments: + contractRev.formData.modifiedStateDirectedPayments, + modifiedPassThroughPayments: + contractRev.formData.modifiedPassThroughPayments, + modifiedPaymentsForMentalDiseaseInstitutions: + contractRev.formData + .modifiedPaymentsForMentalDiseaseInstitutions, + modifiedMedicalLossRatioStandards: + contractRev.formData.modifiedMedicalLossRatioStandards, + modifiedOtherFinancialPaymentIncentive: + contractRev.formData + .modifiedOtherFinancialPaymentIncentive, + modifiedEnrollmentProcess: + contractRev.formData.modifiedEnrollmentProcess, + modifiedGrevienceAndAppeal: + contractRev.formData.modifiedGrevienceAndAppeal, + modifiedNetworkAdequacyStandards: + contractRev.formData.modifiedNetworkAdequacyStandards, + modifiedLengthOfContract: + contractRev.formData.modifiedLengthOfContract, + modifiedNonRiskPaymentArrangements: + contractRev.formData.modifiedNonRiskPaymentArrangements, + }, + }, + rateInfos + } + + return unlockedHealthPlanFormData +} + +export { + convertContractWithRatesRevtoHPPRev, + convertContractWithRatesToUnlockedHPP, + convertContractWithRatesToFormData +} diff --git a/services/app-api/src/domain-models/contractAndRates/index.ts b/services/app-api/src/domain-models/contractAndRates/index.ts index 928919506a..bca12e667a 100644 --- a/services/app-api/src/domain-models/contractAndRates/index.ts +++ b/services/app-api/src/domain-models/contractAndRates/index.ts @@ -13,9 +13,16 @@ export { rateRevisionSchema, } from './revisionTypes' +export { + convertContractWithRatesRevtoHPPRev, + convertContractWithRatesToUnlockedHPP, + convertContractWithRatesToFormData +} from './convertContractWithRatesToHPP' + + export type { ContractType } from './contractTypes' -export type { ContractStatusType, UpdateInfoType } from './updateInfoType' +export type { PackageStatusType , UpdateInfoType } from './updateInfoType' export type { ContractFormDataType, RateFormDataType } from './formDataTypes' diff --git a/services/app-api/src/domain-models/contractAndRates/updateInfoType.ts b/services/app-api/src/domain-models/contractAndRates/updateInfoType.ts index 5dff83667e..3ad7c7ecaf 100644 --- a/services/app-api/src/domain-models/contractAndRates/updateInfoType.ts +++ b/services/app-api/src/domain-models/contractAndRates/updateInfoType.ts @@ -8,8 +8,8 @@ const updateInfoSchema = z.object({ }) type UpdateInfoType = z.infer -type ContractStatusType = z.infer +type PackageStatusType = z.infer -export type { ContractStatusType, UpdateInfoType } +export type { PackageStatusType , UpdateInfoType } export { updateInfoSchema } diff --git a/services/app-api/src/domain-models/healthPlanPackage.ts b/services/app-api/src/domain-models/healthPlanPackage.ts index 4bf659cf01..623e63b8fb 100644 --- a/services/app-api/src/domain-models/healthPlanPackage.ts +++ b/services/app-api/src/domain-models/healthPlanPackage.ts @@ -69,147 +69,9 @@ function packageSubmitters(pkg: HealthPlanPackageType): string[] { return pruneDuplicateEmails(submitters) } -function convertContractToUnlockedHealthPlanPackage( - contract: ContractType -): HealthPlanPackageType | Error { - console.info('Attempting to convert contract to health plan package') - - // Since drafts come in separate on the Contract type, we push it onto the revisions before converting below - if (contract.draftRevision) { - contract.revisions.unshift(contract.draftRevision) - } - - const healthPlanRevisions = - convertContractRevisionToHealthPlanRevision(contract) - - if (healthPlanRevisions instanceof Error) { - return healthPlanRevisions - } - - return { - id: contract.id, - stateCode: contract.stateCode, - revisions: healthPlanRevisions, - } -} - -function convertContractRevisionToHealthPlanRevision( - contract: ContractType -): HealthPlanRevisionType[] | Error { - if (contract.status !== 'DRAFT') { - return new Error( - `Contract with ID: ${contract.id} status is not "DRAFT". Cannot convert to unlocked health plan package` - ) - } - - let healthPlanRevisions: HealthPlanRevisionType[] | Error = [] - for (const contractRev of contract.revisions) { - const unlockedHealthPlanFormData: UnlockedHealthPlanFormDataType = { - id: contractRev.id, - createdAt: contractRev.createdAt, - updatedAt: contractRev.updatedAt, - status: contract.status, - stateCode: contract.stateCode, - stateNumber: contract.stateNumber, - programIDs: contractRev.formData.programIDs, - populationCovered: contractRev.formData.populationCovered, - submissionType: contractRev.formData.submissionType, - riskBasedContract: contractRev.formData.riskBasedContract, - submissionDescription: contractRev.formData.submissionDescription, - stateContacts: contractRev.formData.stateContacts, - addtlActuaryCommunicationPreference: undefined, - addtlActuaryContacts: [], - documents: contractRev.formData.supportingDocuments.map((doc) => ({ - ...doc, - documentCategories: ['CONTRACT_RELATED'], - })) as SubmissionDocument[], - contractType: contractRev.formData.contractType, - contractExecutionStatus: - contractRev.formData.contractExecutionStatus, - contractDocuments: contractRev.formData.contractDocuments.map( - (doc) => ({ - ...doc, - documentCategories: ['CONTRACT'], - }) - ) as SubmissionDocument[], - contractDateStart: contractRev.formData.contractDateStart, - contractDateEnd: contractRev.formData.contractDateEnd, - managedCareEntities: contractRev.formData.managedCareEntities, - federalAuthorities: contractRev.formData.federalAuthorities, - contractAmendmentInfo: { - modifiedProvisions: { - inLieuServicesAndSettings: - contractRev.formData.inLieuServicesAndSettings, - modifiedBenefitsProvided: - contractRev.formData.modifiedBenefitsProvided, - modifiedGeoAreaServed: - contractRev.formData.modifiedGeoAreaServed, - modifiedMedicaidBeneficiaries: - contractRev.formData.modifiedMedicaidBeneficiaries, - modifiedRiskSharingStrategy: - contractRev.formData.modifiedRiskSharingStrategy, - modifiedIncentiveArrangements: - contractRev.formData.modifiedIncentiveArrangements, - modifiedWitholdAgreements: - contractRev.formData.modifiedWitholdAgreements, - modifiedStateDirectedPayments: - contractRev.formData.modifiedStateDirectedPayments, - modifiedPassThroughPayments: - contractRev.formData.modifiedPassThroughPayments, - modifiedPaymentsForMentalDiseaseInstitutions: - contractRev.formData - .modifiedPaymentsForMentalDiseaseInstitutions, - modifiedMedicalLossRatioStandards: - contractRev.formData.modifiedMedicalLossRatioStandards, - modifiedOtherFinancialPaymentIncentive: - contractRev.formData - .modifiedOtherFinancialPaymentIncentive, - modifiedEnrollmentProcess: - contractRev.formData.modifiedEnrollmentProcess, - modifiedGrevienceAndAppeal: - contractRev.formData.modifiedGrevienceAndAppeal, - modifiedNetworkAdequacyStandards: - contractRev.formData.modifiedNetworkAdequacyStandards, - modifiedLengthOfContract: - contractRev.formData.modifiedLengthOfContract, - modifiedNonRiskPaymentArrangements: - contractRev.formData.modifiedNonRiskPaymentArrangements, - }, - }, - rateInfos: [], - } - - const formDataProto = toProtoBuffer(unlockedHealthPlanFormData) - - // check that we can encode then decode with no issues - const domainData = toDomain(formDataProto) - - // If any revision has en error in decoding we break the loop and return an error - if (domainData instanceof Error) { - healthPlanRevisions = new Error( - `Could not convert contract revision with ID: ${contractRev.id} to health plan package revision: ${domainData}` - ) - break - } - - const healthPlanRevision: HealthPlanRevisionType = { - id: contractRev.id, - unlockInfo: contractRev.unlockInfo, - submitInfo: contractRev.submitInfo, - createdAt: contractRev.createdAt, - formDataProto, - } - - healthPlanRevisions.push(healthPlanRevision) - } - - return healthPlanRevisions -} - export { packageCurrentRevision, packageStatus, packageSubmittedAt, packageSubmitters, - convertContractToUnlockedHealthPlanPackage, } diff --git a/services/app-api/src/domain-models/index.ts b/services/app-api/src/domain-models/index.ts index a911f6aff2..f4c299e78b 100644 --- a/services/app-api/src/domain-models/index.ts +++ b/services/app-api/src/domain-models/index.ts @@ -23,9 +23,13 @@ export { packageStatus, packageSubmittedAt, packageSubmitters, - convertContractToUnlockedHealthPlanPackage, } from './healthPlanPackage' +export { + convertContractWithRatesRevtoHPPRev, + convertContractWithRatesToUnlockedHPP, +} from './contractAndRates' + export type { HealthPlanRevisionType, HealthPlanPackageType, diff --git a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts index ef216b7c86..9424bcdc98 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts @@ -2,9 +2,10 @@ import type { Prisma } from '@prisma/client' import type { DocumentCategoryType } from 'app-web/src/common-code/healthPlanFormDataType' import type { ContractFormDataType, + ContractType, RateFormDataType, RateRevisionType, - ContractStatusType, + PackageStatusType, UpdateInfoType, } from '../../domain-models/contractAndRates' @@ -35,13 +36,10 @@ function convertUpdateInfoToDomainModel( } // ----- -function getContractRateStatus( - revisions: - | ContractRevisionTableWithFormData[] - | RateRevisionTableWithFormData[] -): ContractStatusType { +function getContractRateStatus(contractWithRates: ContractType): PackageStatusType { + // need to order revisions from latest to earliest - const revs = revisions.sort( + const revs = contractWithRates.revisions.sort( (revA, revB) => revB.createdAt.getTime() - revA.createdAt.getTime() ) const latestRevision = revs[0] diff --git a/services/app-api/src/resolvers/healthPlanPackage/createHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/createHealthPlanPackage.ts index 2f730616a2..0949a10361 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/createHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/createHealthPlanPackage.ts @@ -12,7 +12,7 @@ import { } from '../attributeHelper' import { GraphQLError } from 'graphql/index' import type { LDService } from '../../launchDarkly/launchDarkly' -import { convertContractToUnlockedHealthPlanPackage } from '../../domain-models' +import { convertContractWithRatesToUnlockedHPP } from '../../domain-models' export function createHealthPlanPackageResolver( store: Store, @@ -93,7 +93,7 @@ export function createHealthPlanPackageResolver( // Now we do the conversions const pkg = - convertContractToUnlockedHealthPlanPackage(contractResult) + convertContractWithRatesToUnlockedHPP(contractResult) if (pkg instanceof Error) { const errMessage = `Error converting draft contract. Message: ${pkg.message}` diff --git a/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.ts index 9a760686ad..f4d3c07100 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.ts @@ -5,7 +5,7 @@ import { isStateUser, isAdminUser, packageStatus, - convertContractToUnlockedHealthPlanPackage, + convertContractWithRatesToUnlockedHPP, } from '../../domain-models' import { isHelpdeskUser } from '../../domain-models/user' import type { QueryResolvers, State } from '../../gen/gqlServer' @@ -66,7 +66,7 @@ export function fetchHealthPlanPackageResolver( } const convertedPkg = - convertContractToUnlockedHealthPlanPackage(contractWithHistory) + convertContractWithRatesToUnlockedHPP(contractWithHistory) if (convertedPkg instanceof Error) { const errMessage = `Issue converting contract. Message: ${convertedPkg.message}` diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index 159e7646ba..79e733146c 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -14,7 +14,6 @@ import { } from '../../../../app-web/src/common-code/healthPlanFormDataType/healthPlanFormData' import type { UpdateInfoType, HealthPlanPackageType } from '../../domain-models' import { - convertContractToUnlockedHealthPlanPackage, isStateUser, packageStatus, packageSubmitters, @@ -38,6 +37,10 @@ import type { LockedHealthPlanFormDataType, } from '../../../../app-web/src/common-code/healthPlanFormDataType' import type { LDService } from '../../launchDarkly/launchDarkly' +import { convertContractWithRatesToFormData, convertContractWithRatesToUnlockedHPP } from '../../domain-models/contractAndRates/convertContractWithRatesToHPP' +import { getContractRateStatus } from '../../postgres/contractAndRates/prismaSharedContractRateHelpers' +import type { Span } from '@opentelemetry/api' +import type { PackageStatusType } from '../../domain-models/contractAndRates' export const SubmissionErrorCodes = ['INCOMPLETE', 'INVALID'] as const type SubmissionErrorCode = (typeof SubmissionErrorCodes)[number] // iterable union type @@ -66,6 +69,30 @@ export function isSubmissionError(err: unknown): err is SubmissionError { return false } +// Throw error if resubmitted without reason or already submitted. +const validateStatusAndUpdateInfo = (status: PackageStatusType, updateInfo: UpdateInfoType, span?: Span, submittedReason?: string) => { + if (status === 'UNLOCKED' && submittedReason) { + updateInfo.updatedReason = submittedReason // ! destrcutive - edit the actual update info that will be attached to submission + } else if (status === 'UNLOCKED' && !submittedReason) { + const errMessage = 'Resubmission requires a reason' + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage) + } else if ( + status === 'RESUBMITTED' || + status === 'SUBMITTED' + ) { + const errMessage = `Attempted to submit an already submitted package.` + logError('submitHealthPlanPackage', errMessage) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'INVALID_PACKAGE_STATUS', + }, + }) + } +} + // This is a state machine transition to turn an Unlocked to Locked Form Data // It will return an error if there are any missing fields that are required to submit // This strategy (returning a different type from validation) is taken from the @@ -143,7 +170,7 @@ export function submitHealthPlanPackageResolver( setResolverDetailsOnActiveSpan('submitHealthPlanPackage', user, span) span?.setAttribute('mcreview.package_id', pkgID) - let initialPackage: HealthPlanPackageType // before submit + let currentFormData: HealthPlanFormDataType // data from revision that is being submitted let updatedPackage: HealthPlanPackageType // after submit //Set updateInfo default to initial submission @@ -168,7 +195,8 @@ export function submitHealthPlanPackageResolver( const stateFromCurrentUser: State['code'] = user.stateCode if (ratesDatabaseRefactor) { - // fetch from package and convert to HealthPlanPackage to match pattern for flag off + // fetch contract and related reates - convert to HealthPlanPackage and proto-ize to match the pattern for flag off\ + // this could be replaced with parsing to locked versus unlocked contracts and rates when types are available const contractWithHistory = await store.findContractWithHistory( input.pkgID ) @@ -194,23 +222,49 @@ export function submitHealthPlanPackageResolver( }, }) } + const maybeHealthPlanPackage = - convertContractToUnlockedHealthPlanPackage(contractWithHistory) + convertContractWithRatesToUnlockedHPP(contractWithHistory) - if (maybeHealthPlanPackage instanceof Error) { - const message = 'TODO CONVERT ERROR' - console.error(message) - throw new Error(message) - } - initialPackage = maybeHealthPlanPackage + if (maybeHealthPlanPackage instanceof Error) { + const errMessage = `Error convert to contractWithHistory health plan package. Message: ${maybeHealthPlanPackage.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'PROTO_DECODE_ERROR', + }, + }) + } + + // Validate user authorized to fetch state + if (contractWithHistory.stateCode !== stateFromCurrentUser) { + logError( + 'submitHealthPlanPackage', + 'user not authorized to fetch data from a different state' + ) + setErrorAttributesOnActiveSpan( + 'user not authorized to fetch data from a different state', + span + ) + throw new ForbiddenError( + 'user not authorized to fetch data from a different state' + ) + } + + validateStatusAndUpdateInfo(getContractRateStatus(contractWithHistory),updateInfo, span, submittedReason || undefined) + + // reassign variable set up before rates feature flag + currentFormData = convertContractWithRatesToFormData(contractWithHistory.revisions[0], contractWithHistory.stateCode, contractWithHistory.stateNumber) } else { // fetch from package flag off - returns HealthPlanPackage - const originalPackage = await store.findHealthPlanPackage( + const initialPackage = await store.findHealthPlanPackage( input.pkgID ) - if (isStoreError(originalPackage) || !originalPackage) { - if (!originalPackage) { + if (isStoreError(initialPackage) || !initialPackage) { + if (!initialPackage) { throw new GraphQLError('Issue finding package.', { extensions: { code: 'NOT_FOUND', @@ -218,7 +272,7 @@ export function submitHealthPlanPackageResolver( }, }) } - const errMessage = `Issue finding a package of type ${originalPackage.code}. Message: ${originalPackage.message}` + const errMessage = `Issue finding a package of type ${initialPackage.code}. Message: ${initialPackage.message}` logError('submitHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new GraphQLError(errMessage, { @@ -229,10 +283,22 @@ export function submitHealthPlanPackageResolver( }) } - initialPackage = originalPackage - } + // unwrap HealthPlanPackage again to make further edits to data + const maybeFormData = toDomain( + initialPackage.revisions[0].formDataProto + ) + if (maybeFormData instanceof Error) { + const errMessage = `Failed to decode draft proto ${maybeFormData}.` + logError('submitHealthPlanPackage', errMessage) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'PROTO_DECODE_ERROR', + }, + }) + } - // Validate user authorized to fetch state + // Validate user authorized to fetch state if (initialPackage.stateCode !== stateFromCurrentUser) { logError( 'submitHealthPlanPackage', @@ -247,52 +313,14 @@ export function submitHealthPlanPackageResolver( ) } - /* - Validate package is in right status to be submitted - - if currently UNLOCKED, also update submitInfo - */ - const initialPackageStatus = packageStatus(initialPackage) - if (initialPackageStatus === 'UNLOCKED' && submittedReason) { - updateInfo.updatedReason = submittedReason - // Throw error if resubmitted without reason. We want to require an input reason for resubmission, but not for - // initial submission - } else if (initialPackageStatus === 'UNLOCKED' && !submittedReason) { - const errMessage = 'Resubmission requires a reason' - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new UserInputError(errMessage) - } else if ( - initialPackageStatus === 'RESUBMITTED' || - initialPackageStatus === 'SUBMITTED' - ) { - const errMessage = `Attempted to submit an already submitted package.` - logError('submitHealthPlanPackage', errMessage) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - }, - }) - } + validateStatusAndUpdateInfo(packageStatus(initialPackage),updateInfo, span, submittedReason || undefined) + // reassign variable set up before rates feature flag + currentFormData = maybeFormData - /* - Unwrap HealthPlanPackage again to make further edits to data - */ - const currentRevision = initialPackage.revisions[0] - const currentFormData = toDomain( - initialPackage.revisions[0].formDataProto - ) - if (currentFormData instanceof Error) { - const errMessage = `Failed to decode draft proto ${currentFormData}.` - logError('submitHealthPlanPackage', errMessage) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'PROTO_DECODE_ERROR', - }, - }) } + + /* Clean form data and remove fields from edits on irrelevant logic branches - CONTRACT_ONLY submission type should not contain any CONTRACT_AND_RATE rates data. @@ -304,19 +332,18 @@ export function submitHealthPlanPackageResolver( isContractOnly(currentFormData) && hasAnyValidRateData(currentFormData) ) { - Object.assign(initialPackage, removeRatesData(currentFormData)) + Object.assign(currentFormData, removeRatesData(currentFormData)) } if (isCHIPOnly(currentFormData)) { Object.assign( - initialPackage, + currentFormData, removeInvalidProvisionsAndAuthorities(currentFormData) ) } /* - Final check of data before submit - - Parse to state submission - */ + Final check of data before submit - Parse to state submission + */ const submissionResult = submit(currentFormData) if (isSubmissionError(submissionResult)) { @@ -333,8 +360,8 @@ export function submitHealthPlanPackageResolver( if (ratesDatabaseRefactor) { // Save the submitted package const updateResult = await store.updateHealthPlanRevision( - initialPackage.id, - currentRevision.id, + input.pkgID, + currentFormData.id, lockedFormData, updateInfo ) @@ -354,8 +381,8 @@ export function submitHealthPlanPackageResolver( } else { // Save the package! const updateResult = await store.updateHealthPlanRevision( - initialPackage.id, - currentRevision.id, + input.pkgID, + currentFormData.id, lockedFormData, updateInfo ) diff --git a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts index 1421d791d3..a8388dbd13 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts @@ -6,7 +6,7 @@ import { } from '../../../../app-web/src/common-code/proto/healthPlanFormDataProto' import type { HealthPlanPackageType } from '../../domain-models' import { - convertContractToUnlockedHealthPlanPackage, + convertContractWithRatesToUnlockedHPP, isStateUser, packageStatus, } from '../../domain-models' @@ -228,7 +228,7 @@ export function updateHealthPlanFormDataResolver( } // Convert back to health plan package - const pkg = convertContractToUnlockedHealthPlanPackage(updateResult) + const pkg = convertContractWithRatesToUnlockedHPP(updateResult) if (pkg instanceof Error) { const errMessage = `Error converting draft contract. Message: ${pkg.message}` From d188e211818778649bf380a83cd7974e3fb92066 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Fri, 1 Sep 2023 17:34:44 -0500 Subject: [PATCH 05/23] Add in tests feature flag, skip status tests - I'm confused what type this is supposed to handle --- .../src/domain-models/healthPlanPackage.ts | 9 ------- .../prismaToDomainModel.test.ts | 2 +- .../submitHealthPlanPackage.test.ts | 24 +++++++++++++++++-- .../submitHealthPlanPackage.ts | 15 +++++++++--- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/services/app-api/src/domain-models/healthPlanPackage.ts b/services/app-api/src/domain-models/healthPlanPackage.ts index 623e63b8fb..1fa26d4044 100644 --- a/services/app-api/src/domain-models/healthPlanPackage.ts +++ b/services/app-api/src/domain-models/healthPlanPackage.ts @@ -4,15 +4,6 @@ import type { HealthPlanPackageType, } from './HealthPlanPackageType' import { pruneDuplicateEmails } from '../emailer/formatters' -import type { ContractType } from './contractAndRates' -import type { - SubmissionDocument, - UnlockedHealthPlanFormDataType, -} from '../../../app-web/src/common-code/healthPlanFormDataType' -import { - toProtoBuffer, - toDomain, -} from '../../../app-web/src/common-code/proto/healthPlanFormDataProto' // submissionStatus computes the current status of the submission based on // the submit/unlock info on its revisions. diff --git a/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts b/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts index a38b7ed14e..3054278729 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts @@ -40,7 +40,7 @@ describe('prismaToDomainModel', () => { }) }) - describe('getContractRateStatus', () => { + describe.skip('getContractRateStatus', () => { // Using type coercion in these tests rather than creating revisions // we just care about unit testing different variations of submitInfo, updateInfo, and createdAt const contractWithUnorderedRevs: { diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts index f5eaf5c40d..b35489b34d 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts @@ -24,8 +24,28 @@ import { } from '../../testHelpers/parameterStoreHelpers' import * as awsSESHelpers from '../../testHelpers/awsSESHelpers' import { testCMSUser, testStateUser } from '../../testHelpers/userHelpers' - -describe('submitHealthPlanPackage', () => { +import type{ FeatureFlagLDConstant, FlagValue } from 'app-web/src/common-code/featureFlags' + +const flagValueTestParameters: { + flagName: FeatureFlagLDConstant + flagValue: FlagValue + testName: string +}[] = [ + { + flagName: 'rates-db-refactor', + flagValue: false, + testName: 'submitHealthPlanPackage with all feature flags off', + }, + { + flagName: 'rates-db-refactor', + flagValue: true, + testName: 'submitHealthPlanPackage with rates-db-refactor on', + }, +] + +describe.each(flagValueTestParameters)( + `Tests $testName`, + ({ flagName, flagValue }) => { const cmsUser = testCMSUser() it('returns a StateSubmission if complete', async () => { const server = await constructTestPostgresServer() diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index 79e733146c..791ff0fb73 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -50,6 +50,7 @@ type SubmissionError = { message: string } + export function isSubmissionError(err: unknown): err is SubmissionError { if (err && typeof err == 'object') { if ('code' in err && 'message' in err) { @@ -253,7 +254,7 @@ export function submitHealthPlanPackageResolver( ) } - validateStatusAndUpdateInfo(getContractRateStatus(contractWithHistory),updateInfo, span, submittedReason || undefined) + validateStatusAndUpdateInfo(getContractRateStatus(contractWithHistory.revisions),updateInfo, span, submittedReason || undefined) // reassign variable set up before rates feature flag currentFormData = convertContractWithRatesToFormData(contractWithHistory.revisions[0], contractWithHistory.stateCode, contractWithHistory.stateNumber) @@ -312,15 +313,23 @@ export function submitHealthPlanPackageResolver( 'user not authorized to fetch data from a different state' ) } + const status = packageStatus(initialPackage) + if (status instanceof Error) { + throw new GraphQLError(status.message, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'INVALID_PACKAGE_STATUS', + } + }) + } - validateStatusAndUpdateInfo(packageStatus(initialPackage),updateInfo, span, submittedReason || undefined) + validateStatusAndUpdateInfo(status,updateInfo, span, submittedReason || undefined) // reassign variable set up before rates feature flag currentFormData = maybeFormData } - /* Clean form data and remove fields from edits on irrelevant logic branches - CONTRACT_ONLY submission type should not contain any CONTRACT_AND_RATE rates data. From 723a552cbf5cba814287ee2fac6ec45d22b5eaaf Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Fri, 1 Sep 2023 17:48:09 -0500 Subject: [PATCH 06/23] Add back status tests - remove calculated status in submit --- .../contractAndRates/prismaSharedContractRateHelpers.ts | 5 ++--- .../postgres/contractAndRates/prismaToDomainModel.test.ts | 2 +- .../healthPlanPackage/submitHealthPlanPackage.test.ts | 2 +- .../healthPlanPackage/submitHealthPlanPackage.ts | 5 ++--- .../healthPlanFormDataType/healthPlanFormData.ts | 8 ++++---- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts index 9424bcdc98..7364237459 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts @@ -2,7 +2,6 @@ import type { Prisma } from '@prisma/client' import type { DocumentCategoryType } from 'app-web/src/common-code/healthPlanFormDataType' import type { ContractFormDataType, - ContractType, RateFormDataType, RateRevisionType, PackageStatusType, @@ -36,10 +35,10 @@ function convertUpdateInfoToDomainModel( } // ----- -function getContractRateStatus(contractWithRates: ContractType): PackageStatusType { +function getContractRateStatus(revisions: ContractRevisionTableWithFormData[]| RateRevisionTableWithFormData[]): PackageStatusType { // need to order revisions from latest to earliest - const revs = contractWithRates.revisions.sort( + const revs = revisions.sort( (revA, revB) => revB.createdAt.getTime() - revA.createdAt.getTime() ) const latestRevision = revs[0] diff --git a/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts b/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts index 3054278729..a38b7ed14e 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.test.ts @@ -40,7 +40,7 @@ describe('prismaToDomainModel', () => { }) }) - describe.skip('getContractRateStatus', () => { + describe('getContractRateStatus', () => { // Using type coercion in these tests rather than creating revisions // we just care about unit testing different variations of submitInfo, updateInfo, and createdAt const contractWithUnorderedRevs: { diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts index b35489b34d..9e8641afe3 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts @@ -47,7 +47,7 @@ describe.each(flagValueTestParameters)( `Tests $testName`, ({ flagName, flagValue }) => { const cmsUser = testCMSUser() - it('returns a StateSubmission if complete', async () => { + it.only('returns a StateSubmission if complete', async () => { const server = await constructTestPostgresServer() // setup diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index 791ff0fb73..b509aeee2e 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -38,7 +38,6 @@ import type { } from '../../../../app-web/src/common-code/healthPlanFormDataType' import type { LDService } from '../../launchDarkly/launchDarkly' import { convertContractWithRatesToFormData, convertContractWithRatesToUnlockedHPP } from '../../domain-models/contractAndRates/convertContractWithRatesToHPP' -import { getContractRateStatus } from '../../postgres/contractAndRates/prismaSharedContractRateHelpers' import type { Span } from '@opentelemetry/api' import type { PackageStatusType } from '../../domain-models/contractAndRates' @@ -254,7 +253,7 @@ export function submitHealthPlanPackageResolver( ) } - validateStatusAndUpdateInfo(getContractRateStatus(contractWithHistory.revisions),updateInfo, span, submittedReason || undefined) + validateStatusAndUpdateInfo(contractWithHistory.status,updateInfo, span, submittedReason || undefined) // reassign variable set up before rates feature flag currentFormData = convertContractWithRatesToFormData(contractWithHistory.revisions[0], contractWithHistory.stateCode, contractWithHistory.stateNumber) @@ -264,7 +263,7 @@ export function submitHealthPlanPackageResolver( input.pkgID ) - if (isStoreError(initialPackage) || !initialPackage) { + if (isStoreError(initialPackage) || !initialPackage) { if (!initialPackage) { throw new GraphQLError('Issue finding package.', { extensions: { diff --git a/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.ts b/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.ts index f1a5d0e6f7..f5ea77f2f7 100644 --- a/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.ts +++ b/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.ts @@ -329,8 +329,8 @@ const convertRateSupportingDocs = ( } const removeRatesData = ( - pkg: UnlockedHealthPlanFormDataType -): UnlockedHealthPlanFormDataType => { + pkg: HealthPlanFormDataType +): HealthPlanFormDataType => { pkg.rateInfos = [] pkg.addtlActuaryContacts = [] pkg.addtlActuaryCommunicationPreference = undefined @@ -342,8 +342,8 @@ const removeRatesData = ( // Remove any provisions and federal authorities that aren't valid for population type (e.g. CHIP) // since user can change theses submission type fields on unlock and not necesarily update the contract details const removeInvalidProvisionsAndAuthorities = ( - pkg: UnlockedHealthPlanFormDataType -): UnlockedHealthPlanFormDataType => { + pkg: HealthPlanFormDataType +): HealthPlanFormDataType => { // remove invalid provisions if (isContractWithProvisions(pkg) && pkg.contractAmendmentInfo) { const validProvisionsKeys = generateApplicableProvisionsList(pkg) From 71356463e4e08e469fda389cfab5ac70f23683e5 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Fri, 8 Sep 2023 16:27:01 -0500 Subject: [PATCH 07/23] Tests compiling - working on emails --- .../domain-models/contractAndRates/index.ts | 8 +- services/app-api/src/domain-models/index.ts | 14 +- .../src/postgres/contractAndRates/index.ts | 4 + .../contractAndRates/submitContract.ts | 3 +- .../postgres/contractAndRates/submitRate.ts | 14 +- .../contractAndRates/updateDraftContract.ts | 2 +- .../app-api/src/postgres/postgresStore.ts | 13 + .../submitHealthPlanPackage.test.ts | 1470 +++++++++-------- .../submitHealthPlanPackage.ts | 174 +- .../app-api/src/testHelpers/storeHelpers.ts | 10 + 10 files changed, 909 insertions(+), 803 deletions(-) diff --git a/services/app-api/src/domain-models/contractAndRates/index.ts b/services/app-api/src/domain-models/contractAndRates/index.ts index bca12e667a..91ade24b14 100644 --- a/services/app-api/src/domain-models/contractAndRates/index.ts +++ b/services/app-api/src/domain-models/contractAndRates/index.ts @@ -1,7 +1,5 @@ export { rateSchema, draftRateSchema } from './rateTypes' -export type { RateType } from './rateTypes' - export { contractSchema, draftContractSchema } from './contractTypes' export { contractFormDataSchema, rateFormDataSchema } from './formDataTypes' @@ -16,13 +14,13 @@ export { export { convertContractWithRatesRevtoHPPRev, convertContractWithRatesToUnlockedHPP, - convertContractWithRatesToFormData + convertContractWithRatesToFormData, } from './convertContractWithRatesToHPP' - export type { ContractType } from './contractTypes' +export type { RateType } from './rateTypes' -export type { PackageStatusType , UpdateInfoType } from './updateInfoType' +export type { PackageStatusType, UpdateInfoType } from './updateInfoType' export type { ContractFormDataType, RateFormDataType } from './formDataTypes' diff --git a/services/app-api/src/domain-models/index.ts b/services/app-api/src/domain-models/index.ts index f4c299e78b..73c011347b 100644 --- a/services/app-api/src/domain-models/index.ts +++ b/services/app-api/src/domain-models/index.ts @@ -25,11 +25,23 @@ export { packageSubmitters, } from './healthPlanPackage' -export { +export { convertContractWithRatesRevtoHPPRev, convertContractWithRatesToUnlockedHPP, } from './contractAndRates' +export type { + ContractType, + ContractRevisionType, + ContractRevisionWithRatesType, + ContractFormDataType, + RateType, + RateRevisionType, + RateRevisionWithContractsType, + RateFormDataType, + PackageStatusType, +} from './contractAndRates' + export type { HealthPlanRevisionType, HealthPlanPackageType, diff --git a/services/app-api/src/postgres/contractAndRates/index.ts b/services/app-api/src/postgres/contractAndRates/index.ts index 46c9a05405..3a66211255 100644 --- a/services/app-api/src/postgres/contractAndRates/index.ts +++ b/services/app-api/src/postgres/contractAndRates/index.ts @@ -1,5 +1,9 @@ export type { InsertContractArgsType } from './insertContract' export type { UpdateContractArgsType } from './updateDraftContract' +export type { SubmitContractArgsType } from './submitContract' +export type { SubmitRateArgsType } from './submitRate' +export { submitContract } from './submitContract' +export { submitRate } from './submitRate' export { insertDraftContract } from './insertContract' export { findContractWithHistory } from './findContractWithHistory' export { updateDraftContract } from './updateDraftContract' diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index 0680119800..832f1a89b5 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -6,7 +6,7 @@ import type { UpdateInfoType } from '../../domain-models' import { includeFirstSubmittedRateRev } from './prismaSubmittedRateHelpers' type SubmitContractArgsType = { - contractID: string + contractID: string // revision ID submittedByUserID: UpdateInfoType['updatedBy'] submitReason: UpdateInfoType['updatedReason'] } @@ -156,3 +156,4 @@ async function submitContract( } export { submitContract } +export type { SubmitContractArgsType } diff --git a/services/app-api/src/postgres/contractAndRates/submitRate.ts b/services/app-api/src/postgres/contractAndRates/submitRate.ts index 45ac23241c..36f22a7406 100644 --- a/services/app-api/src/postgres/contractAndRates/submitRate.ts +++ b/services/app-api/src/postgres/contractAndRates/submitRate.ts @@ -47,19 +47,6 @@ async function submitRate( (c) => c.revisions[0] ) - const everyRelatedContractIsSubmitted = relatedContractRevs.every( - (rev) => rev !== undefined - ) - - if (!everyRelatedContractIsSubmitted) { - const message = - 'Attempted to submit a rate related to a contract that has not been submitted' - - console.error(message) - - return new Error(message) - } - const updated = await tx.rateRevisionTable.update({ where: { id: currentRev.id, @@ -158,3 +145,4 @@ async function submitRate( } export { submitRate } +export type { SubmitRateArgsType } diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContract.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContract.ts index 72a37787f6..307d785f2d 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContract.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContract.ts @@ -7,7 +7,7 @@ import type { ContractFormDataType } from '../../domain-models/contractAndRates' type ContractFormEditable = Partial type UpdateContractArgsType = { - contractID: string + contractID: string //revision ID formData: ContractFormEditable rateIDs: string[] } diff --git a/services/app-api/src/postgres/postgresStore.ts b/services/app-api/src/postgres/postgresStore.ts index 2fb7891ee6..4aab8c6c66 100644 --- a/services/app-api/src/postgres/postgresStore.ts +++ b/services/app-api/src/postgres/postgresStore.ts @@ -20,6 +20,7 @@ import type { QuestionResponseType, InsertQuestionResponseArgs, StateType, + RateType, } from '../domain-models' import { findPrograms, findStatePrograms } from '../postgres' import type { StoreError } from './storeError' @@ -52,10 +53,14 @@ import { insertDraftContract, findContractWithHistory, updateDraftContract, + submitRate, + submitContract, } from './contractAndRates' import type { InsertContractArgsType, UpdateContractArgsType, + SubmitContractArgsType, + SubmitRateArgsType, } from './contractAndRates' type Store = { @@ -145,6 +150,12 @@ type Store = { updateDraftContract: ( args: UpdateContractArgsType ) => Promise + + submitContract: ( + args: SubmitContractArgsType + ) => Promise + + submitRate: (args: SubmitRateArgsType) => Promise } function NewPostgresStore(client: PrismaClient): Store { @@ -206,6 +217,8 @@ function NewPostgresStore(client: PrismaClient): Store { findContractWithHistory: (args) => findContractWithHistory(client, args), updateDraftContract: (args) => updateDraftContract(client, args), + submitContract: (args) => submitContract(client, args), + submitRate: (args) => submitRate(client, args), } } diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts index 9e8641afe3..ec12589936 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts @@ -24,7 +24,10 @@ import { } from '../../testHelpers/parameterStoreHelpers' import * as awsSESHelpers from '../../testHelpers/awsSESHelpers' import { testCMSUser, testStateUser } from '../../testHelpers/userHelpers' -import type{ FeatureFlagLDConstant, FlagValue } from 'app-web/src/common-code/featureFlags' +import type { + FeatureFlagLDConstant, + FlagValue, +} from 'app-web/src/common-code/featureFlags' const flagValueTestParameters: { flagName: FeatureFlagLDConstant @@ -46,255 +49,235 @@ const flagValueTestParameters: { describe.each(flagValueTestParameters)( `Tests $testName`, ({ flagName, flagValue }) => { - const cmsUser = testCMSUser() - it.only('returns a StateSubmission if complete', async () => { - const server = await constructTestPostgresServer() - - // setup - const initialPkg = await createAndUpdateTestHealthPlanPackage( - server, - {} - ) - const draft = latestFormData(initialPkg) - const draftID = draft.id - - await new Promise((resolve) => setTimeout(resolve, 2000)) - - // submit - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + const cmsUser = testCMSUser() + it('returns a StateSubmission if complete', async () => { + const server = await constructTestPostgresServer() + + // setup + const initialPkg = await createAndUpdateTestHealthPlanPackage( + server, + {} + ) + const draft = latestFormData(initialPkg) + const draftID = draft.id + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // submit + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - }, - }) - - expect(submitResult.errors).toBeUndefined() - const createdID = submitResult?.data?.submitHealthPlanPackage.pkg.id - - // test result - const pkg = await fetchTestHealthPlanPackageById(server, createdID) - - const resultDraft = latestFormData(pkg) - - // The submission fields should still be set - expect(resultDraft.id).toEqual(createdID) - expect(resultDraft.submissionType).toBe('CONTRACT_AND_RATES') - expect(resultDraft.programIDs).toEqual([defaultFloridaProgram().id]) - // check that the stateNumber is being returned the same - expect(resultDraft.stateNumber).toEqual(draft.stateNumber) - expect(resultDraft.submissionDescription).toBe('An updated submission') - expect(resultDraft.documents).toEqual(draft.documents) - - // Contract details fields should still be set - expect(resultDraft.contractType).toEqual(draft.contractType) - expect(resultDraft.contractExecutionStatus).toEqual( - draft.contractExecutionStatus - ) - expect(resultDraft.contractDateStart).toEqual(draft.contractDateStart) - expect(resultDraft.contractDateEnd).toEqual(draft.contractDateEnd) - expect(resultDraft.managedCareEntities).toEqual( - draft.managedCareEntities - ) - expect(resultDraft.contractDocuments).toEqual(draft.contractDocuments) - - expect(resultDraft.federalAuthorities).toEqual(draft.federalAuthorities) - - if (resultDraft.status == 'DRAFT') { - throw new Error('Not a locked submission') - } - - // submittedAt should be set to today's date - const today = new Date() - const expectedDate = today.toISOString().split('T')[0] - expect(pkg.initiallySubmittedAt).toEqual(expectedDate) - - // UpdatedAt should be after the former updatedAt - const resultUpdated = new Date(resultDraft.updatedAt) - const createdUpdated = new Date(draft.updatedAt) - expect( - resultUpdated.getTime() - createdUpdated.getTime() - ).toBeGreaterThan(0) - }, 20000) + }) - it('returns an error if there are no contract documents attached', async () => { - const server = await constructTestPostgresServer() + expect(submitResult.errors).toBeUndefined() + const createdID = submitResult?.data?.submitHealthPlanPackage.pkg.id + + // test result + const pkg = await fetchTestHealthPlanPackageById(server, createdID) + + const resultDraft = latestFormData(pkg) + + // The submission fields should still be set + expect(resultDraft.id).toEqual(createdID) + expect(resultDraft.submissionType).toBe('CONTRACT_AND_RATES') + expect(resultDraft.programIDs).toEqual([defaultFloridaProgram().id]) + // check that the stateNumber is being returned the same + expect(resultDraft.stateNumber).toEqual(draft.stateNumber) + expect(resultDraft.submissionDescription).toBe( + 'An updated submission' + ) + expect(resultDraft.documents).toEqual(draft.documents) + + // Contract details fields should still be set + expect(resultDraft.contractType).toEqual(draft.contractType) + expect(resultDraft.contractExecutionStatus).toEqual( + draft.contractExecutionStatus + ) + expect(resultDraft.contractDateStart).toEqual( + draft.contractDateStart + ) + expect(resultDraft.contractDateEnd).toEqual(draft.contractDateEnd) + expect(resultDraft.managedCareEntities).toEqual( + draft.managedCareEntities + ) + expect(resultDraft.contractDocuments).toEqual( + draft.contractDocuments + ) + + expect(resultDraft.federalAuthorities).toEqual( + draft.federalAuthorities + ) + + if (resultDraft.status == 'DRAFT') { + throw new Error('Not a locked submission') + } - const draft = await createAndUpdateTestHealthPlanPackage(server, { - documents: [], - contractDocuments: [], - }) - const draftID = draft.id + // submittedAt should be set to today's date + const today = new Date() + const expectedDate = today.toISOString().split('T')[0] + expect(pkg.initiallySubmittedAt).toEqual(expectedDate) + + // UpdatedAt should be after the former updatedAt + const resultUpdated = new Date(resultDraft.updatedAt) + const createdUpdated = new Date(draft.updatedAt) + expect( + resultUpdated.getTime() - createdUpdated.getTime() + ).toBeGreaterThan(0) + }, 20000) + + it('returns an error if there are no contract documents attached', async () => { + const server = await constructTestPostgresServer() + + const draft = await createAndUpdateTestHealthPlanPackage(server, { + documents: [], + contractDocuments: [], + }) + const draftID = draft.id - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - }, - }) - - expect(submitResult.errors).toBeDefined() - - expect(submitResult.errors?.[0].extensions?.code).toBe('BAD_USER_INPUT') - expect(submitResult.errors?.[0].extensions?.message).toBe( - 'formData must have valid documents' - ) - }) - - it('returns an error if the package is already SUBMITTED', async () => { - const server = await constructTestPostgresServer() + }) - const draft = await createAndSubmitTestHealthPlanPackage(server) - const draftID = draft.id + expect(submitResult.errors).toBeDefined() - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, - }, - }, + expect(submitResult.errors?.[0].extensions?.code).toBe( + 'BAD_USER_INPUT' + ) + expect(submitResult.errors?.[0].extensions?.message).toBe( + 'formData must have valid documents' + ) }) - expect(submitResult.errors).toBeDefined() + it('returns an error if the package is already SUBMITTED', async () => { + const server = await constructTestPostgresServer() + + const draft = await createAndSubmitTestHealthPlanPackage(server) + const draftID = draft.id - expect(submitResult.errors?.[0].extensions).toEqual( - expect.objectContaining({ - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - exception: { - locations: undefined, - message: - 'Attempted to submit an already submitted package.', - path: undefined, + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, }) - ) - expect(submitResult.errors?.[0].message).toBe( - 'Attempted to submit an already submitted package.' - ) - }) + expect(submitResult.errors).toBeDefined() - it('returns an error if there are no contract details fields', async () => { - const server = await constructTestPostgresServer() - - const draft = await createAndUpdateTestHealthPlanPackage(server, { - contractType: undefined, - contractExecutionStatus: undefined, - managedCareEntities: [], - federalAuthorities: [], - }) + expect(submitResult.errors?.[0].extensions).toEqual( + expect.objectContaining({ + code: 'INTERNAL_SERVER_ERROR', + cause: 'INVALID_PACKAGE_STATUS', + exception: { + locations: undefined, + message: + 'Attempted to submit an already submitted package.', + path: undefined, + }, + }) + ) - const draftID = draft.id - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, - }, - }, + expect(submitResult.errors?.[0].message).toBe( + 'Attempted to submit an already submitted package.' + ) }) - expect(submitResult.errors).toBeDefined() - - expect(submitResult.errors?.[0].extensions?.code).toBe('BAD_USER_INPUT') - expect(submitResult.errors?.[0].extensions?.message).toBe( - 'formData is missing required contract fields' - ) - }) + it('returns an error if there are no contract details fields', async () => { + const server = await constructTestPostgresServer() - it('returns an error if there are missing rate details fields for submission type', async () => { - const server = await constructTestPostgresServer() + const draft = await createAndUpdateTestHealthPlanPackage(server, { + contractType: undefined, + contractExecutionStatus: undefined, + managedCareEntities: [], + federalAuthorities: [], + }) - const draft = await createAndUpdateTestHealthPlanPackage(server, { - submissionType: 'CONTRACT_AND_RATES', - rateInfos: [ - { - rateType: 'NEW' as const, - rateDateStart: new Date(Date.UTC(2025, 5, 1)), - rateDateEnd: new Date(Date.UTC(2026, 4, 30)), - rateDateCertified: new Date(Date.UTC(2025, 3, 15)), - rateDocuments: [ - { - name: 'rateDocument.pdf', - s3URL: 'fakeS3URL', - documentCategories: ['RATES' as const], - }, - ], - supportingDocuments: [], - rateProgramIDs: ['3b8d8fa1-1fa6-4504-9c5b-ef522877fe1e'], - actuaryContacts: [], - actuaryCommunicationPreference: 'OACT_TO_ACTUARY' as const, - packagesWithSharedRateCerts: [], + const draftID = draft.id + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - ], - }) + }) - const draftID = draft.id - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, - }, - }, - }) + expect(submitResult.errors).toBeDefined() - expect(submitResult.errors).toBeDefined() + expect(submitResult.errors?.[0].extensions?.code).toBe( + 'BAD_USER_INPUT' + ) + expect(submitResult.errors?.[0].extensions?.message).toBe( + 'formData is missing required contract fields' + ) + }) - expect(submitResult.errors?.[0].extensions?.code).toBe('BAD_USER_INPUT') - expect(submitResult.errors?.[0].extensions?.message).toBe( - 'formData is missing required rate fields' - ) - }) + it('returns an error if there are missing rate details fields for submission type', async () => { + const server = await constructTestPostgresServer() - it('does not remove any rate data from CONTRACT_AND_RATES submissionType and submits successfully', async () => { - const server = await constructTestPostgresServer() + const draft = await createAndUpdateTestHealthPlanPackage(server, { + submissionType: 'CONTRACT_AND_RATES', + rateInfos: [ + { + rateType: 'NEW' as const, + rateDateStart: new Date(Date.UTC(2025, 5, 1)), + rateDateEnd: new Date(Date.UTC(2026, 4, 30)), + rateDateCertified: new Date(Date.UTC(2025, 3, 15)), + rateDocuments: [ + { + name: 'rateDocument.pdf', + s3URL: 'fakeS3URL', + documentCategories: ['RATES' as const], + }, + ], + supportingDocuments: [], + rateProgramIDs: [ + '3b8d8fa1-1fa6-4504-9c5b-ef522877fe1e', + ], + actuaryContacts: [], + actuaryCommunicationPreference: + 'OACT_TO_ACTUARY' as const, + packagesWithSharedRateCerts: [], + }, + ], + }) - //Create and update a contract and rate submission to contract only with rate data - const draft = await createAndUpdateTestHealthPlanPackage(server, { - submissionType: 'CONTRACT_AND_RATES', - documents: [ - { - name: 'contract_supporting_that_applies_to_a_rate_also.pdf', - s3URL: 'fakeS3URL', - documentCategories: [ - 'CONTRACT_RELATED' as const, - 'RATES_RELATED' as const, - ], - }, - { - name: 'rate_only_supporting_doc.pdf', - s3URL: 'fakeS3URL', - documentCategories: ['RATES_RELATED' as const], + const draftID = draft.id + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - ], - }) - - const draftCurrentRevision = draft.revisions[0].node - const draftPackageData = base64ToDomain( - draftCurrentRevision.formDataProto - ) + }) - if (draftPackageData instanceof Error) { - throw new Error(draftPackageData.message) - } + expect(submitResult.errors).toBeDefined() - const submitResult = await submitTestHealthPlanPackage(server, draft.id) - const currentRevision = submitResult.revisions[0].node - const packageData = base64ToDomain(currentRevision.formDataProto) + expect(submitResult.errors?.[0].extensions?.code).toBe( + 'BAD_USER_INPUT' + ) + expect(submitResult.errors?.[0].extensions?.message).toBe( + 'formData is missing required rate fields' + ) + }) - if (packageData instanceof Error) { - throw new Error(packageData.message) - } + it('does not remove any rate data from CONTRACT_AND_RATES submissionType and submits successfully', async () => { + const server = await constructTestPostgresServer() - expect(packageData).toEqual( - expect.objectContaining({ - addtlActuaryContacts: draftPackageData.addtlActuaryContacts, + //Create and update a contract and rate submission to contract only with rate data + const draft = await createAndUpdateTestHealthPlanPackage(server, { + submissionType: 'CONTRACT_AND_RATES', documents: [ { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', @@ -311,108 +294,126 @@ describe.each(flagValueTestParameters)( }, ], }) - ) - }) - it('removes any rate data from CONTRACT_ONLY submissionType and submits successfully', async () => { - const server = await constructTestPostgresServer() + const draftCurrentRevision = draft.revisions[0].node + const draftPackageData = base64ToDomain( + draftCurrentRevision.formDataProto + ) - //Create and update a contract and rate submission to contract only with rate data - const draft = await createAndUpdateTestHealthPlanPackage(server, { - submissionType: 'CONTRACT_ONLY', - documents: [ - { - name: 'contract_supporting_that_applies_to_a_rate_also.pdf', - s3URL: 'fakeS3URL', - documentCategories: [ - 'CONTRACT_RELATED' as const, - 'RATES_RELATED' as const, - ], - }, - { - name: 'rate_only_supporting_doc.pdf', - s3URL: 'fakeS3URL', - documentCategories: ['RATES_RELATED' as const], - }, - ], - }) + if (draftPackageData instanceof Error) { + throw new Error(draftPackageData.message) + } - const submitResult = await submitTestHealthPlanPackage(server, draft.id) + const submitResult = await submitTestHealthPlanPackage( + server, + draft.id + ) + const currentRevision = submitResult.revisions[0].node + const packageData = base64ToDomain(currentRevision.formDataProto) - const currentRevision = submitResult.revisions[0].node - const packageData = base64ToDomain(currentRevision.formDataProto) + if (packageData instanceof Error) { + throw new Error(packageData.message) + } - if (packageData instanceof Error) { - throw new Error(packageData.message) - } + expect(packageData).toEqual( + expect.objectContaining({ + addtlActuaryContacts: draftPackageData.addtlActuaryContacts, + documents: [ + { + name: 'contract_supporting_that_applies_to_a_rate_also.pdf', + s3URL: 'fakeS3URL', + documentCategories: [ + 'CONTRACT_RELATED' as const, + 'RATES_RELATED' as const, + ], + }, + { + name: 'rate_only_supporting_doc.pdf', + s3URL: 'fakeS3URL', + documentCategories: ['RATES_RELATED' as const], + }, + ], + }) + ) + }) + + it('removes any rate data from CONTRACT_ONLY submissionType and submits successfully', async () => { + const server = await constructTestPostgresServer() - expect(packageData).toEqual( - expect.objectContaining({ - rateInfos: expect.arrayContaining([]), - addtlActuaryContacts: expect.arrayContaining([]), + //Create and update a contract and rate submission to contract only with rate data + const draft = await createAndUpdateTestHealthPlanPackage(server, { + submissionType: 'CONTRACT_ONLY', documents: [ { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', s3URL: 'fakeS3URL', - documentCategories: ['CONTRACT_RELATED'], + documentCategories: [ + 'CONTRACT_RELATED' as const, + 'RATES_RELATED' as const, + ], }, { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', - documentCategories: ['CONTRACT_RELATED'], + documentCategories: ['RATES_RELATED' as const], }, ], }) - ) - }) - it('removes any invalid modified provisions from CHIP submission and submits successfully', async () => { - const server = await constructTestPostgresServer() + const submitResult = await submitTestHealthPlanPackage( + server, + draft.id + ) - //Create and update a submission as if the user edited and changed population covered after filling out yes/nos - const draft = await createAndUpdateTestHealthPlanPackage(server, { - contractType: 'AMENDMENT', - populationCovered: 'CHIP', - federalAuthorities: ['TITLE_XXI'], - contractAmendmentInfo: { - modifiedProvisions: { - inLieuServicesAndSettings: true, - modifiedBenefitsProvided: true, - modifiedGeoAreaServed: false, - modifiedMedicaidBeneficiaries: false, - modifiedRiskSharingStrategy: true, - modifiedIncentiveArrangements: true, - modifiedWitholdAgreements: true, - modifiedStateDirectedPayments: true, - modifiedPassThroughPayments: true, - modifiedPaymentsForMentalDiseaseInstitutions: true, - modifiedMedicalLossRatioStandards: false, - modifiedOtherFinancialPaymentIncentive: false, - modifiedEnrollmentProcess: false, - modifiedGrevienceAndAppeal: false, - modifiedNetworkAdequacyStandards: false, - modifiedLengthOfContract: false, - modifiedNonRiskPaymentArrangements: false, - }, - }, - }) + const currentRevision = submitResult.revisions[0].node + const packageData = base64ToDomain(currentRevision.formDataProto) + + if (packageData instanceof Error) { + throw new Error(packageData.message) + } - const submitResult = await submitTestHealthPlanPackage(server, draft.id) + expect(packageData).toEqual( + expect.objectContaining({ + rateInfos: expect.arrayContaining([]), + addtlActuaryContacts: expect.arrayContaining([]), + documents: [ + { + name: 'contract_supporting_that_applies_to_a_rate_also.pdf', + s3URL: 'fakeS3URL', + documentCategories: ['CONTRACT_RELATED'], + }, + { + name: 'rate_only_supporting_doc.pdf', + s3URL: 'fakeS3URL', + documentCategories: ['CONTRACT_RELATED'], + }, + ], + }) + ) + }) - const currentRevision = submitResult.revisions[0].node - const packageData = base64ToDomain(currentRevision.formDataProto) + it('removes any invalid modified provisions from CHIP submission and submits successfully', async () => { + const server = await constructTestPostgresServer() - if (packageData instanceof Error) { - throw new Error(packageData.message) - } - expect(packageData).toEqual( - expect.objectContaining({ + //Create and update a submission as if the user edited and changed population covered after filling out yes/nos + const draft = await createAndUpdateTestHealthPlanPackage(server, { + contractType: 'AMENDMENT', + populationCovered: 'CHIP', + federalAuthorities: ['TITLE_XXI'], contractAmendmentInfo: { modifiedProvisions: { + inLieuServicesAndSettings: true, modifiedBenefitsProvided: true, modifiedGeoAreaServed: false, modifiedMedicaidBeneficiaries: false, + modifiedRiskSharingStrategy: true, + modifiedIncentiveArrangements: true, + modifiedWitholdAgreements: true, + modifiedStateDirectedPayments: true, + modifiedPassThroughPayments: true, + modifiedPaymentsForMentalDiseaseInstitutions: true, modifiedMedicalLossRatioStandards: false, + modifiedOtherFinancialPaymentIncentive: false, modifiedEnrollmentProcess: false, modifiedGrevienceAndAppeal: false, modifiedNetworkAdequacyStandards: false, @@ -421,494 +422,539 @@ describe.each(flagValueTestParameters)( }, }, }) - ) - }) - - it('removes any invalid federal authorities from CHIP submission and submits successfully', async () => { - const server = await constructTestPostgresServer() - - //Create and update a submission as if the user edited and changed population covered after filling out yes/nos - const draft = await createAndUpdateTestHealthPlanPackage(server, { - populationCovered: 'CHIP', - federalAuthorities: [ - 'STATE_PLAN', - 'WAIVER_1915B', - 'WAIVER_1115', - 'VOLUNTARY', - 'BENCHMARK', - 'TITLE_XXI', - ], - }) - const submitResult = await submitTestHealthPlanPackage(server, draft.id) + const submitResult = await submitTestHealthPlanPackage( + server, + draft.id + ) - const currentRevision = submitResult.revisions[0].node - const packageData = base64ToDomain(currentRevision.formDataProto) + const currentRevision = submitResult.revisions[0].node + const packageData = base64ToDomain(currentRevision.formDataProto) - if (packageData instanceof Error) { - throw new Error(packageData.message) - } - expect(packageData).toEqual( - expect.objectContaining({ - federalAuthorities: ['WAIVER_1115', 'TITLE_XXI'], + if (packageData instanceof Error) { + throw new Error(packageData.message) + } + expect(packageData).toEqual( + expect.objectContaining({ + contractAmendmentInfo: { + modifiedProvisions: { + modifiedBenefitsProvided: true, + modifiedGeoAreaServed: false, + modifiedMedicaidBeneficiaries: false, + modifiedMedicalLossRatioStandards: false, + modifiedEnrollmentProcess: false, + modifiedGrevienceAndAppeal: false, + modifiedNetworkAdequacyStandards: false, + modifiedLengthOfContract: false, + modifiedNonRiskPaymentArrangements: false, + }, + }, + }) + ) + }) + + it('removes any invalid federal authorities from CHIP submission and submits successfully', async () => { + const server = await constructTestPostgresServer() + + //Create and update a submission as if the user edited and changed population covered after filling out yes/nos + const draft = await createAndUpdateTestHealthPlanPackage(server, { + populationCovered: 'CHIP', + federalAuthorities: [ + 'STATE_PLAN', + 'WAIVER_1915B', + 'WAIVER_1115', + 'VOLUNTARY', + 'BENCHMARK', + 'TITLE_XXI', + ], }) - ) - }) - it('sends two emails', async () => { - const mockEmailer = testEmailer() + const submitResult = await submitTestHealthPlanPackage( + server, + draft.id + ) - //mock invoke email submit lambda - const server = await constructTestPostgresServer({ - emailer: mockEmailer, - }) - const draft = await createAndUpdateTestHealthPlanPackage(server, {}) - const draftID = draft.id + const currentRevision = submitResult.revisions[0].node + const packageData = base64ToDomain(currentRevision.formDataProto) - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, - }, - }, + if (packageData instanceof Error) { + throw new Error(packageData.message) + } + expect(packageData).toEqual( + expect.objectContaining({ + federalAuthorities: ['WAIVER_1115', 'TITLE_XXI'], + }) + ) }) - expect(submitResult.errors).toBeUndefined() - expect(mockEmailer.sendEmail).toHaveBeenCalledTimes(2) - }) + it('sends two emails', async () => { + const mockEmailer = testEmailer() + + //mock invoke email submit lambda + const server = await constructTestPostgresServer({ + emailer: mockEmailer, + }) + const draft = await createAndUpdateTestHealthPlanPackage(server, {}) + const draftID = draft.id + + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, + }, + }) - it('send CMS email to CMS if submission is valid', async () => { - const config = testEmailConfig() - const mockEmailer = testEmailer(config) - //mock invoke email submit lambda - const server = await constructTestPostgresServer({ - emailer: mockEmailer, + expect(submitResult.errors).toBeUndefined() + expect(mockEmailer.sendEmail).toHaveBeenCalledTimes(2) }) - const draft = await createAndUpdateTestHealthPlanPackage(server, {}) - const draftID = draft.id - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + it('send CMS email to CMS if submission is valid', async () => { + const config = testEmailConfig() + const mockEmailer = testEmailer(config) + //mock invoke email submit lambda + const server = await constructTestPostgresServer({ + emailer: mockEmailer, + }) + const draft = await createAndUpdateTestHealthPlanPackage(server, {}) + const draftID = draft.id + + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - }, - }) + }) - const currentRevision = - submitResult?.data?.submitHealthPlanPackage?.pkg.revisions[0].node + const currentRevision = + submitResult?.data?.submitHealthPlanPackage?.pkg.revisions[0] + .node - const sub = base64ToDomain(currentRevision.formDataProto) - if (sub instanceof Error) { - throw sub - } + const sub = base64ToDomain(currentRevision.formDataProto) + if (sub instanceof Error) { + throw sub + } - const programs = [defaultFloridaProgram()] - const name = packageName(sub, programs) - const stateAnalystsEmails = getTestStateAnalystsEmails(sub.stateCode) + const programs = [defaultFloridaProgram()] + const name = packageName(sub, programs) + const stateAnalystsEmails = getTestStateAnalystsEmails( + sub.stateCode + ) + + const cmsEmails = [ + ...config.devReviewTeamEmails, + ...stateAnalystsEmails, + ] + + // email subject line is correct for CMS email + expect(mockEmailer.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: expect.stringContaining( + `New Managed Care Submission: ${name}` + ), + sourceEmail: config.emailSource, + toAddresses: expect.arrayContaining(Array.from(cmsEmails)), + }) + ) + }) + + it('does send email when request for state analysts emails fails', async () => { + const config = testEmailConfig() + const mockEmailer = testEmailer(config) + //mock invoke email submit lambda + const mockEmailParameterStore = mockEmailParameterStoreError() + const server = await constructTestPostgresServer({ + emailer: mockEmailer, + emailParameterStore: mockEmailParameterStore, + }) + const draft = await createAndUpdateTestHealthPlanPackage(server, {}) + const draftID = draft.id + + await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, + }, + }) - const cmsEmails = [ - ...config.devReviewTeamEmails, - ...stateAnalystsEmails, - ] + expect(mockEmailer.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + toAddresses: expect.arrayContaining( + Array.from(config.devReviewTeamEmails) + ), + }) + ) + }) + + it('does log error when request for state specific analysts emails failed', async () => { + const mockEmailParameterStore = mockEmailParameterStoreError() + const consoleErrorSpy = jest.spyOn(console, 'error') + const error = { + error: 'No store found', + message: 'getStateAnalystsEmails failed', + operation: 'getStateAnalystsEmails', + status: 'ERROR', + } - // email subject line is correct for CMS email - expect(mockEmailer.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: expect.stringContaining( - `New Managed Care Submission: ${name}` - ), - sourceEmail: config.emailSource, - toAddresses: expect.arrayContaining(Array.from(cmsEmails)), + const server = await constructTestPostgresServer({ + emailParameterStore: mockEmailParameterStore, }) - ) - }) - - it('does send email when request for state analysts emails fails', async () => { - const config = testEmailConfig() - const mockEmailer = testEmailer(config) - //mock invoke email submit lambda - const mockEmailParameterStore = mockEmailParameterStoreError() - const server = await constructTestPostgresServer({ - emailer: mockEmailer, - emailParameterStore: mockEmailParameterStore, - }) - const draft = await createAndUpdateTestHealthPlanPackage(server, {}) - const draftID = draft.id - - await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + const draft = await createAndUpdateTestHealthPlanPackage(server, {}) + const draftID = draft.id + + await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - }, - }) - - expect(mockEmailer.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - toAddresses: expect.arrayContaining( - Array.from(config.devReviewTeamEmails) - ), }) - ) - }) - - it('does log error when request for state specific analysts emails failed', async () => { - const mockEmailParameterStore = mockEmailParameterStoreError() - const consoleErrorSpy = jest.spyOn(console, 'error') - const error = { - error: 'No store found', - message: 'getStateAnalystsEmails failed', - operation: 'getStateAnalystsEmails', - status: 'ERROR', - } - - const server = await constructTestPostgresServer({ - emailParameterStore: mockEmailParameterStore, - }) - const draft = await createAndUpdateTestHealthPlanPackage(server, {}) - const draftID = draft.id - await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, - }, - }, + expect(consoleErrorSpy).toHaveBeenCalledWith(error) }) - expect(consoleErrorSpy).toHaveBeenCalledWith(error) - }) - - it('send state email to logged in user if submission is valid', async () => { - const config = testEmailConfig() - const mockEmailer = testEmailer(config) - const server = await constructTestPostgresServer({ - emailer: mockEmailer, - }) + it('send state email to logged in user if submission is valid', async () => { + const config = testEmailConfig() + const mockEmailer = testEmailer(config) + const server = await constructTestPostgresServer({ + emailer: mockEmailer, + }) - const currentUser = defaultContext().user // need this to reach into gql tests and understand who current user is - const draft = await createAndUpdateTestHealthPlanPackage(server, {}) - const draftID = draft.id + const currentUser = defaultContext().user // need this to reach into gql tests and understand who current user is + const draft = await createAndUpdateTestHealthPlanPackage(server, {}) + const draftID = draft.id - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - }, - }) - - expect(submitResult.errors).toBeUndefined() + }) - const currentRevision = - submitResult?.data?.submitHealthPlanPackage?.pkg.revisions[0].node + expect(submitResult.errors).toBeUndefined() - const sub = base64ToDomain(currentRevision.formDataProto) - if (sub instanceof Error) { - throw sub - } + const currentRevision = + submitResult?.data?.submitHealthPlanPackage?.pkg.revisions[0] + .node - const programs = [defaultFloridaProgram()] - const ratePrograms = [defaultFloridaRateProgram()] - const name = packageName(sub, programs) - const rateName = generateRateName(sub, sub.rateInfos[0], ratePrograms) + const sub = base64ToDomain(currentRevision.formDataProto) + if (sub instanceof Error) { + throw sub + } - expect(mockEmailer.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: expect.stringContaining(`${name} was sent to CMS`), - sourceEmail: config.emailSource, - toAddresses: expect.arrayContaining([currentUser.email]), - bodyHTML: expect.stringContaining(rateName), + const programs = [defaultFloridaProgram()] + const ratePrograms = [defaultFloridaRateProgram()] + const name = packageName(sub, programs) + const rateName = generateRateName( + sub, + sub.rateInfos[0], + ratePrograms + ) + + expect(mockEmailer.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: expect.stringContaining(`${name} was sent to CMS`), + sourceEmail: config.emailSource, + toAddresses: expect.arrayContaining([currentUser.email]), + bodyHTML: expect.stringContaining(rateName), + }) + ) + }) + + it('send state email to submitter if submission is valid', async () => { + const mockEmailer = testEmailer() + const server = await constructTestPostgresServer({ + emailer: mockEmailer, + context: { + user: testStateUser({ + email: 'notspiderman@example.com', + }), + }, }) - ) - }) - - it('send state email to submitter if submission is valid', async () => { - const mockEmailer = testEmailer() - const server = await constructTestPostgresServer({ - emailer: mockEmailer, - context: { - user: testStateUser({ - email: 'notspiderman@example.com', - }), - }, - }) - const draft = await createAndUpdateTestHealthPlanPackage(server, {}) - const draftID = draft.id - - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + const draft = await createAndUpdateTestHealthPlanPackage(server, {}) + const draftID = draft.id + + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - }, - }) - - expect(submitResult.errors).toBeUndefined() + }) - const currentRevision = - submitResult?.data?.submitHealthPlanPackage?.pkg.revisions[0].node + expect(submitResult.errors).toBeUndefined() - const sub = base64ToDomain(currentRevision.formDataProto) - if (sub instanceof Error) { - throw sub - } + const currentRevision = + submitResult?.data?.submitHealthPlanPackage?.pkg.revisions[0] + .node - const programs = [defaultFloridaProgram()] - const name = packageName(sub, programs) + const sub = base64ToDomain(currentRevision.formDataProto) + if (sub instanceof Error) { + throw sub + } - expect(mockEmailer.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: expect.stringContaining(`${name} was sent to CMS`), - toAddresses: expect.arrayContaining([ - 'notspiderman@example.com', - ]), + const programs = [defaultFloridaProgram()] + const name = packageName(sub, programs) + + expect(mockEmailer.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: expect.stringContaining(`${name} was sent to CMS`), + toAddresses: expect.arrayContaining([ + 'notspiderman@example.com', + ]), + }) + ) + }) + + it('send CMS email to CMS on valid resubmission', async () => { + const config = testEmailConfig() + const mockEmailer = testEmailer(config) + //mock invoke email submit lambda + const stateServer = await constructTestPostgresServer({ + emailer: mockEmailer, }) - ) - }) - - it('send CMS email to CMS on valid resubmission', async () => { - const config = testEmailConfig() - const mockEmailer = testEmailer(config) - //mock invoke email submit lambda - const stateServer = await constructTestPostgresServer({ - emailer: mockEmailer, - }) - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - }) - - await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Test unlock reason.' - ) - - const submitResult = await stateServer.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - submittedReason: 'Test resubmitted reason', + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, }, - }, - }) - - const currentRevision = - submitResult?.data?.submitHealthPlanPackage?.pkg.revisions[0].node - - const sub = base64ToDomain(currentRevision.formDataProto) - if (sub instanceof Error) { - throw sub - } - - const programs = [defaultFloridaProgram()] - const name = packageName(sub, programs) - - // email subject line is correct for CMS email and contains correct email body text - expect(mockEmailer.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: expect.stringContaining(`${name} was resubmitted`), - sourceEmail: config.emailSource, - bodyText: expect.stringContaining( - `The state completed their edits on submission ${name}` - ), - toAddresses: expect.arrayContaining( - Array.from(config.devReviewTeamEmails) - ), }) - ) - }) - - it('send state email to state contacts and all submitters on valid resubmission', async () => { - const config = testEmailConfig() - const mockEmailer = testEmailer(config) - //mock invoke email submit lambda - const stateServer = await constructTestPostgresServer({ - context: { - user: testStateUser({ - email: 'alsonotspiderman@example.com', - }), - }, - }) - - const stateServerTwo = await constructTestPostgresServer({ - emailer: mockEmailer, - context: { - user: testStateUser({ - email: 'notspiderman@example.com', - }), - }, - }) - - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - }) - - await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Test unlock reason.' - ) - - const submitResult = await resubmitTestHealthPlanPackage( - stateServerTwo, - stateSubmission.id, - 'Test resubmission reason' - ) - - const currentRevision = submitResult?.revisions[0].node + await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Test unlock reason.' + ) + + const submitResult = await stateServer.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + submittedReason: 'Test resubmitted reason', + }, + }, + }) - const sub = base64ToDomain(currentRevision.formDataProto) - if (sub instanceof Error) { - throw sub - } + const currentRevision = + submitResult?.data?.submitHealthPlanPackage?.pkg.revisions[0] + .node - const programs = [defaultFloridaProgram()] - const name = packageName(sub, programs) + const sub = base64ToDomain(currentRevision.formDataProto) + if (sub instanceof Error) { + throw sub + } - // email subject line is correct for CMS email and contains correct email body text - expect(mockEmailer.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: expect.stringContaining(`${name} was resubmitted`), - sourceEmail: config.emailSource, - toAddresses: expect.arrayContaining([ - 'alsonotspiderman@example.com', - 'notspiderman@example.com', - sub.stateContacts[0].email, - ]), + const programs = [defaultFloridaProgram()] + const name = packageName(sub, programs) + + // email subject line is correct for CMS email and contains correct email body text + expect(mockEmailer.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: expect.stringContaining(`${name} was resubmitted`), + sourceEmail: config.emailSource, + bodyText: expect.stringContaining( + `The state completed their edits on submission ${name}` + ), + toAddresses: expect.arrayContaining( + Array.from(config.devReviewTeamEmails) + ), + }) + ) + }) + + it('send state email to state contacts and all submitters on valid resubmission', async () => { + const config = testEmailConfig() + const mockEmailer = testEmailer(config) + //mock invoke email submit lambda + const stateServer = await constructTestPostgresServer({ + context: { + user: testStateUser({ + email: 'alsonotspiderman@example.com', + }), + }, }) - ) - }) - it('does not send any emails if submission fails', async () => { - const mockEmailer = testEmailer() - const server = await constructTestPostgresServer({ - emailer: mockEmailer, - }) - const draft = await createAndUpdateTestHealthPlanPackage(server, { - submissionType: 'CONTRACT_AND_RATES', - rateInfos: [ - { - rateDateStart: new Date(Date.UTC(2025, 5, 1)), - rateDateEnd: new Date(Date.UTC(2026, 4, 30)), - rateDateCertified: undefined, - rateDocuments: [], - supportingDocuments: [], - actuaryContacts: [], - packagesWithSharedRateCerts: [], + const stateServerTwo = await constructTestPostgresServer({ + emailer: mockEmailer, + context: { + user: testStateUser({ + email: 'notspiderman@example.com', + }), }, - ], - }) - const draftID = draft.id + }) - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, }, - }, - }) + }) - expect(submitResult.errors).toBeDefined() - expect(mockEmailer.sendEmail).not.toHaveBeenCalled() - }) + await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Test unlock reason.' + ) - it('errors when SES email has failed.', async () => { - const mockEmailer = testEmailer() + const submitResult = await resubmitTestHealthPlanPackage( + stateServerTwo, + stateSubmission.id, + 'Test resubmission reason' + ) - jest.spyOn(awsSESHelpers, 'testSendSESEmail').mockImplementation( - async () => { - throw new Error('Network error occurred') + const currentRevision = submitResult?.revisions[0].node + + const sub = base64ToDomain(currentRevision.formDataProto) + if (sub instanceof Error) { + throw sub } - ) - //mock invoke email submit lambda - const server = await constructTestPostgresServer({ - emailer: mockEmailer, - }) - const draft = await createAndUpdateTestHealthPlanPackage(server, {}) - const draftID = draft.id + const programs = [defaultFloridaProgram()] + const name = packageName(sub, programs) + + // email subject line is correct for CMS email and contains correct email body text + expect(mockEmailer.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: expect.stringContaining(`${name} was resubmitted`), + sourceEmail: config.emailSource, + toAddresses: expect.arrayContaining([ + 'alsonotspiderman@example.com', + 'notspiderman@example.com', + sub.stateContacts[0].email, + ]), + }) + ) + }) + + it('does not send any emails if submission fails', async () => { + const mockEmailer = testEmailer() + const server = await constructTestPostgresServer({ + emailer: mockEmailer, + }) + const draft = await createAndUpdateTestHealthPlanPackage(server, { + submissionType: 'CONTRACT_AND_RATES', + rateInfos: [ + { + rateDateStart: new Date(Date.UTC(2025, 5, 1)), + rateDateEnd: new Date(Date.UTC(2026, 4, 30)), + rateDateCertified: undefined, + rateDocuments: [], + supportingDocuments: [], + actuaryContacts: [], + packagesWithSharedRateCerts: [], + }, + ], + }) + const draftID = draft.id - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - }, + }) + + expect(submitResult.errors).toBeDefined() + expect(mockEmailer.sendEmail).not.toHaveBeenCalled() }) - // expect errors from submission - expect(submitResult.errors).toBeDefined() + it('errors when SES email has failed.', async () => { + const mockEmailer = testEmailer() - // expect sendEmail to have been called, so we know it did not error earlier - expect(mockEmailer.sendEmail).toHaveBeenCalled() + jest.spyOn(awsSESHelpers, 'testSendSESEmail').mockImplementation( + async () => { + throw new Error('Network error occurred') + } + ) - // expect correct graphql error. - expect(submitResult.errors?.[0]).toEqual( - expect.objectContaining({ - message: 'Email failed', - path: ['submitHealthPlanPackage'], - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'EMAIL_ERROR', - exception: { - message: 'Email failed', + //mock invoke email submit lambda + const server = await constructTestPostgresServer({ + emailer: mockEmailer, + }) + const draft = await createAndUpdateTestHealthPlanPackage(server, {}) + const draftID = draft.id + + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, }, }, }) - ) - }) - - it('errors when risk based question is undefined', async () => { - const server = await constructTestPostgresServer() - // setup - const initialPkg = await createAndUpdateTestHealthPlanPackage(server, { - riskBasedContract: undefined, + // expect errors from submission + expect(submitResult.errors).toBeDefined() + + // expect sendEmail to have been called, so we know it did not error earlier + expect(mockEmailer.sendEmail).toHaveBeenCalled() + + // expect correct graphql error. + expect(submitResult.errors?.[0]).toEqual( + expect.objectContaining({ + message: 'Email failed', + path: ['submitHealthPlanPackage'], + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'EMAIL_ERROR', + exception: { + message: 'Email failed', + }, + }, + }) + ) }) - const draft = latestFormData(initialPkg) - const draftID = draft.id - await new Promise((resolve) => setTimeout(resolve, 2000)) + it('errors when risk based question is undefined', async () => { + const server = await constructTestPostgresServer() - // submit - const submitResult = await server.executeOperation({ - query: SUBMIT_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: draftID, + // setup + const initialPkg = await createAndUpdateTestHealthPlanPackage( + server, + { + riskBasedContract: undefined, + } + ) + const draft = latestFormData(initialPkg) + const draftID = draft.id + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // submit + const submitResult = await server.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftID, + }, }, - }, - }) + }) - expect(submitResult.errors).toBeDefined() - expect(submitResult.errors?.[0].extensions?.message).toBe( - 'formData is missing required contract fields' - ) - }, 20000) -}) + expect(submitResult.errors).toBeDefined() + expect(submitResult.errors?.[0].extensions?.message).toBe( + 'formData is missing required contract fields' + ) + }, 20000) + } +) describe('Feature flagged population coverage question test', () => { it('errors when population coverage question is undefined', async () => { diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index b509aeee2e..fe0b706f05 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -37,7 +37,10 @@ import type { LockedHealthPlanFormDataType, } from '../../../../app-web/src/common-code/healthPlanFormDataType' import type { LDService } from '../../launchDarkly/launchDarkly' -import { convertContractWithRatesToFormData, convertContractWithRatesToUnlockedHPP } from '../../domain-models/contractAndRates/convertContractWithRatesToHPP' +import { + convertContractWithRatesToFormData, + convertContractWithRatesToUnlockedHPP, +} from '../../domain-models/contractAndRates/convertContractWithRatesToHPP' import type { Span } from '@opentelemetry/api' import type { PackageStatusType } from '../../domain-models/contractAndRates' @@ -49,7 +52,6 @@ type SubmissionError = { message: string } - export function isSubmissionError(err: unknown): err is SubmissionError { if (err && typeof err == 'object') { if ('code' in err && 'message' in err) { @@ -70,18 +72,20 @@ export function isSubmissionError(err: unknown): err is SubmissionError { } // Throw error if resubmitted without reason or already submitted. -const validateStatusAndUpdateInfo = (status: PackageStatusType, updateInfo: UpdateInfoType, span?: Span, submittedReason?: string) => { +const validateStatusAndUpdateInfo = ( + status: PackageStatusType, + updateInfo: UpdateInfoType, + span?: Span, + submittedReason?: string +) => { if (status === 'UNLOCKED' && submittedReason) { - updateInfo.updatedReason = submittedReason // ! destrcutive - edit the actual update info that will be attached to submission + updateInfo.updatedReason = submittedReason // !destructive - edits the actual update info attached to submission } else if (status === 'UNLOCKED' && !submittedReason) { const errMessage = 'Resubmission requires a reason' logError('submitHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new UserInputError(errMessage) - } else if ( - status === 'RESUBMITTED' || - status === 'SUBMITTED' - ) { + } else if (status === 'RESUBMITTED' || status === 'SUBMITTED') { const errMessage = `Attempted to submit an already submitted package.` logError('submitHealthPlanPackage', errMessage) throw new GraphQLError(errMessage, { @@ -171,7 +175,8 @@ export function submitHealthPlanPackageResolver( span?.setAttribute('mcreview.package_id', pkgID) let currentFormData: HealthPlanFormDataType // data from revision that is being submitted - let updatedPackage: HealthPlanPackageType // after submit + let contractRevisionID: string // id for latest contract revision - this will be passed to submit + let updatedPackage: HealthPlanPackageType // updated data from revision after submit //Set updateInfo default to initial submission const updateInfo: UpdateInfoType = { @@ -226,44 +231,54 @@ export function submitHealthPlanPackageResolver( const maybeHealthPlanPackage = convertContractWithRatesToUnlockedHPP(contractWithHistory) - if (maybeHealthPlanPackage instanceof Error) { - const errMessage = `Error convert to contractWithHistory health plan package. Message: ${maybeHealthPlanPackage.message}` - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'PROTO_DECODE_ERROR', - }, - }) - } + if (maybeHealthPlanPackage instanceof Error) { + const errMessage = `Error convert to contractWithHistory health plan package. Message: ${maybeHealthPlanPackage.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'PROTO_DECODE_ERROR', + }, + }) + } - // Validate user authorized to fetch state - if (contractWithHistory.stateCode !== stateFromCurrentUser) { - logError( - 'submitHealthPlanPackage', - 'user not authorized to fetch data from a different state' - ) - setErrorAttributesOnActiveSpan( - 'user not authorized to fetch data from a different state', - span - ) - throw new ForbiddenError( - 'user not authorized to fetch data from a different state' - ) - } + // Validate user authorized to fetch state + if (contractWithHistory.stateCode !== stateFromCurrentUser) { + logError( + 'submitHealthPlanPackage', + 'user not authorized to fetch data from a different state' + ) + setErrorAttributesOnActiveSpan( + 'user not authorized to fetch data from a different state', + span + ) + throw new ForbiddenError( + 'user not authorized to fetch data from a different state' + ) + } - validateStatusAndUpdateInfo(contractWithHistory.status,updateInfo, span, submittedReason || undefined) + validateStatusAndUpdateInfo( + contractWithHistory.status, + updateInfo, + span, + submittedReason || undefined + ) // reassign variable set up before rates feature flag - currentFormData = convertContractWithRatesToFormData(contractWithHistory.revisions[0], contractWithHistory.stateCode, contractWithHistory.stateNumber) + currentFormData = convertContractWithRatesToFormData( + contractWithHistory.revisions[0], + contractWithHistory.stateCode, + contractWithHistory.stateNumber + ) + contractRevisionID = contractWithHistory.revisions[0].id } else { // fetch from package flag off - returns HealthPlanPackage const initialPackage = await store.findHealthPlanPackage( input.pkgID ) - if (isStoreError(initialPackage) || !initialPackage) { + if (isStoreError(initialPackage) || !initialPackage) { if (!initialPackage) { throw new GraphQLError('Issue finding package.', { extensions: { @@ -298,37 +313,41 @@ export function submitHealthPlanPackageResolver( }) } - // Validate user authorized to fetch state - if (initialPackage.stateCode !== stateFromCurrentUser) { - logError( - 'submitHealthPlanPackage', - 'user not authorized to fetch data from a different state' - ) - setErrorAttributesOnActiveSpan( - 'user not authorized to fetch data from a different state', - span - ) - throw new ForbiddenError( - 'user not authorized to fetch data from a different state' - ) - } + // Validate user authorized to fetch state + if (initialPackage.stateCode !== stateFromCurrentUser) { + logError( + 'submitHealthPlanPackage', + 'user not authorized to fetch data from a different state' + ) + setErrorAttributesOnActiveSpan( + 'user not authorized to fetch data from a different state', + span + ) + throw new ForbiddenError( + 'user not authorized to fetch data from a different state' + ) + } const status = packageStatus(initialPackage) if (status instanceof Error) { - throw new GraphQLError(status.message, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - } + throw new GraphQLError(status.message, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'INVALID_PACKAGE_STATUS', + }, }) } - validateStatusAndUpdateInfo(status,updateInfo, span, submittedReason || undefined) + validateStatusAndUpdateInfo( + status, + updateInfo, + span, + submittedReason || undefined + ) // reassign variable set up before rates feature flag currentFormData = maybeFormData - + contractRevisionID = initialPackage.revisions[0].id } - /* Clean form data and remove fields from edits on irrelevant logic branches - CONTRACT_ONLY submission type should not contain any CONTRACT_AND_RATE rates data. @@ -366,15 +385,14 @@ export function submitHealthPlanPackageResolver( const lockedFormData = submissionResult if (ratesDatabaseRefactor) { - // Save the submitted package - const updateResult = await store.updateHealthPlanRevision( - input.pkgID, - currentFormData.id, - lockedFormData, - updateInfo - ) - if (isStoreError(updateResult)) { - const errMessage = `Issue updating a package of type ${updateResult.code}. Message: ${updateResult.message}` + // Save the contract! + const submitResult = await store.submitContract({ + contractID: contractRevisionID, + submittedByUserID: user.id, + submitReason: updateInfo.updatedReason, + }) + if (isStoreError(submitResult)) { + const errMessage = `Issue updating a package of type ${submitResult.code}. Message: ${submitResult.message}` logError('submitHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new GraphQLError(errMessage, { @@ -383,14 +401,30 @@ export function submitHealthPlanPackageResolver( cause: 'DB_ERROR', }, }) + } else if (submitResult instanceof Error) { + throw new Error('Still to do - figuring out error handling') } + const maybeSubmittedPkg = + convertContractWithRatesToUnlockedHPP(submitResult) - updatedPackage = updateResult + if (maybeSubmittedPkg instanceof Error) { + const errMessage = `Error converting draft contract. Message: ${maybeSubmittedPkg.message}` + logError('createHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'PROTO_DECODE_ERROR', + }, + }) + } + + updatedPackage = maybeSubmittedPkg } else { // Save the package! const updateResult = await store.updateHealthPlanRevision( input.pkgID, - currentFormData.id, + contractRevisionID, lockedFormData, updateInfo ) diff --git a/services/app-api/src/testHelpers/storeHelpers.ts b/services/app-api/src/testHelpers/storeHelpers.ts index 1d8770d3ba..1942c120a7 100644 --- a/services/app-api/src/testHelpers/storeHelpers.ts +++ b/services/app-api/src/testHelpers/storeHelpers.ts @@ -113,6 +113,16 @@ function mockStoreThatErrors(): Store { 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' ) }, + submitContract: async (_ID) => { + return new Error( + 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' + ) + }, + submitRate: async (_ID) => { + return new Error( + 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' + ) + }, } } From 2f8e8f11adc22c7dde4d6ae83e7946506fad902f Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Mon, 11 Sep 2023 18:24:13 -0500 Subject: [PATCH 08/23] Add submitRate --- .../submitHealthPlanPackage.ts | 194 +++++++++++------- 1 file changed, 117 insertions(+), 77 deletions(-) diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index fe0b706f05..f7ddc9119f 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -42,7 +42,10 @@ import { convertContractWithRatesToUnlockedHPP, } from '../../domain-models/contractAndRates/convertContractWithRatesToHPP' import type { Span } from '@opentelemetry/api' -import type { PackageStatusType } from '../../domain-models/contractAndRates' +import type { + PackageStatusType, + RateType, +} from '../../domain-models/contractAndRates' export const SubmissionErrorCodes = ['INCOMPLETE', 'INVALID'] as const type SubmissionErrorCode = (typeof SubmissionErrorCodes)[number] // iterable union type @@ -101,9 +104,20 @@ const validateStatusAndUpdateInfo = ( // It will return an error if there are any missing fields that are required to submit // This strategy (returning a different type from validation) is taken from the // "parse, don't validate" article: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ -function submit( +function parseAndSubmit( draft: HealthPlanFormDataType ): LockedHealthPlanFormDataType | SubmissionError { + // Remove fields from edits on irrelevant logic branches + // - CONTRACT_ONLY submission type should not contain any CONTRACT_AND_RATE rates data. + // - CHIP_ONLY population covered should not contain any provision or authority relevant to other population. + // - We delete at submission instead of update to preserve rates data in case user did not intend or would like to revert the submission type before submitting. + if (isContractOnly(draft) && hasAnyValidRateData(draft)) { + Object.assign(draft, removeRatesData(draft)) + } + if (isCHIPOnly(draft)) { + Object.assign(draft, removeInvalidProvisionsAndAuthorities(draft)) + } + const maybeStateSubmission: Record = { ...draft, status: 'SUBMITTED', @@ -174,9 +188,11 @@ export function submitHealthPlanPackageResolver( setResolverDetailsOnActiveSpan('submitHealthPlanPackage', user, span) span?.setAttribute('mcreview.package_id', pkgID) - let currentFormData: HealthPlanFormDataType // data from revision that is being submitted - let contractRevisionID: string // id for latest contract revision - this will be passed to submit - let updatedPackage: HealthPlanPackageType // updated data from revision after submit + // Set up variables that are used across the feature flag boundary + let initialFormData: HealthPlanFormDataType // data from revision sent to resolver + let contractRevisionID: string // id for latest contract revision to reference later + let lockedFormData: LockedHealthPlanFormDataType // updated data (parsed and cleaned) passed to submit + let updatedPackage: HealthPlanPackageType // updated package returned from submit //Set updateInfo default to initial submission const updateInfo: UpdateInfoType = { @@ -266,12 +282,96 @@ export function submitHealthPlanPackageResolver( ) // reassign variable set up before rates feature flag - currentFormData = convertContractWithRatesToFormData( + initialFormData = convertContractWithRatesToFormData( contractWithHistory.revisions[0], contractWithHistory.stateCode, contractWithHistory.stateNumber ) contractRevisionID = contractWithHistory.revisions[0].id + + // Final clean + check of data before submit - parse to state submission + const maybeLocked = parseAndSubmit(initialFormData) + + if (isSubmissionError(maybeLocked)) { + const errMessage = maybeLocked.message + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage, { + message: maybeLocked.message, + }) + } + // If there are rates, submit those first + if (initialFormData.rateInfos.length > 0) { + const ratePromises: Promise[] = [] + contractWithHistory.revisions[0].rateRevisions.forEach( + (rateRev) => { + ratePromises.push( + store.submitRate({ + rateID: rateRev.id, + submittedByUserID: user.id, + submitReason: updateInfo.updatedReason, + }) + ) + } + ) + + const submitRatesResult = await Promise.all(ratePromises) + if (isStoreError(submitRatesResult)) { + const errMessage = `Issue updating a package of type ${submitRatesResult.code}. Message: ${submitRatesResult.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } else if (submitRatesResult instanceof Error) { + throw new Error( + 'Still to do - figuring out error handling and if this path is possible' + ) + } + } + + // then submit the contract! + const submitContractResult = await store.submitContract({ + contractID: contractRevisionID, + submittedByUserID: user.id, + submitReason: updateInfo.updatedReason, + }) + if (isStoreError(submitContractResult)) { + const errMessage = `Issue updating a package of type ${submitContractResult.code}. Message: ${submitContractResult.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } else if (submitContractResult instanceof Error) { + throw new Error( + 'Still to do - figuring out error handling and if this path is possible' + ) + } + const maybeSubmittedPkg = + convertContractWithRatesToUnlockedHPP(submitContractResult) + + if (maybeSubmittedPkg instanceof Error) { + const errMessage = `Error converting draft contract. Message: ${maybeSubmittedPkg.message}` + logError('createHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'PROTO_DECODE_ERROR', + }, + }) + } + + // set variables used across feature flag boundary + lockedFormData = maybeLocked + updatedPackage = maybeSubmittedPkg } else { // fetch from package flag off - returns HealthPlanPackage const initialPackage = await store.findHealthPlanPackage( @@ -343,89 +443,27 @@ export function submitHealthPlanPackageResolver( span, submittedReason || undefined ) - // reassign variable set up before rates feature flag - currentFormData = maybeFormData + // reassign variable set up before rates feature flagx + initialFormData = maybeFormData contractRevisionID = initialPackage.revisions[0].id - } - - /* - Clean form data and remove fields from edits on irrelevant logic branches - - CONTRACT_ONLY submission type should not contain any CONTRACT_AND_RATE rates data. - - CHIP_ONLY population covered should not contain any provision or authority relevant to other population. - - We delete at submission instead of update to preserve rates data in case user did not intend or would like to revert the submission type before submitting. - */ - - if ( - isContractOnly(currentFormData) && - hasAnyValidRateData(currentFormData) - ) { - Object.assign(currentFormData, removeRatesData(currentFormData)) - } - if (isCHIPOnly(currentFormData)) { - Object.assign( - currentFormData, - removeInvalidProvisionsAndAuthorities(currentFormData) - ) - } - /* - Final check of data before submit - Parse to state submission - */ - const submissionResult = submit(currentFormData) - - if (isSubmissionError(submissionResult)) { - const errMessage = submissionResult.message - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new UserInputError(errMessage, { - message: submissionResult.message, - }) - } + // Final clean + check of data before submit - parse to state submission + const maybeLocked = parseAndSubmit(initialFormData) - const lockedFormData = submissionResult - - if (ratesDatabaseRefactor) { - // Save the contract! - const submitResult = await store.submitContract({ - contractID: contractRevisionID, - submittedByUserID: user.id, - submitReason: updateInfo.updatedReason, - }) - if (isStoreError(submitResult)) { - const errMessage = `Issue updating a package of type ${submitResult.code}. Message: ${submitResult.message}` + if (isSubmissionError(maybeLocked)) { + const errMessage = maybeLocked.message logError('submitHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } else if (submitResult instanceof Error) { - throw new Error('Still to do - figuring out error handling') - } - const maybeSubmittedPkg = - convertContractWithRatesToUnlockedHPP(submitResult) - - if (maybeSubmittedPkg instanceof Error) { - const errMessage = `Error converting draft contract. Message: ${maybeSubmittedPkg.message}` - logError('createHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'PROTO_DECODE_ERROR', - }, + throw new UserInputError(errMessage, { + message: maybeLocked.message, }) } - updatedPackage = maybeSubmittedPkg - } else { // Save the package! const updateResult = await store.updateHealthPlanRevision( input.pkgID, contractRevisionID, - lockedFormData, + maybeLocked, updateInfo ) if (isStoreError(updateResult)) { @@ -440,6 +478,8 @@ export function submitHealthPlanPackageResolver( }) } + // set variables used across feature flag boundary + lockedFormData = maybeLocked updatedPackage = updateResult } From 13c5c25b983063bda992e07748ba68f0e2f1b862 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Wed, 13 Sep 2023 10:01:46 -0500 Subject: [PATCH 09/23] Backtrack docs changes - added those to #1922 instead --- .images/change-history-feature-ui.png | Bin 33380 -> 0 bytes .../contract-rate-change-history.md | 24 +++++------------- 2 files changed, 6 insertions(+), 18 deletions(-) delete mode 100644 .images/change-history-feature-ui.png diff --git a/.images/change-history-feature-ui.png b/.images/change-history-feature-ui.png deleted file mode 100644 index 50a818c66577b7dbaf6d7e8c754451f592e47444..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33380 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 diff --git a/docs/technical-design/contract-rate-change-history.md b/docs/technical-design/contract-rate-change-history.md index 66a3d23f8c..008b4710f9 100644 --- a/docs/technical-design/contract-rate-change-history.md +++ b/docs/technical-design/contract-rate-change-history.md @@ -5,37 +5,25 @@ title: Calculating contract and Rate Change History ## Calculating contract and Rate Change History ## Overview -Change history is the feature of MC-Review where the application stores full copies of changes to submission data over time. The data is displayed to users on the submission summary page. +Change history is a feature of MC-Review where we track changes to the submission data over time and display this content to users. This document details how the change history is calculated for contract and rate data and which database fields are used. - ![Change History on Submission Summary page](../../.images/change-history-feature-ui.png) - - This document details how the change history is calculated for contract and rate data and which database fields are used. +[ADD IMAGE] ## Contraints - MC-Review must track actions on contract or a rate (submit, unlock, resubmit) alongside the full form data present at that that momment in the database. This data is used for CMS reporting and audit purposes. It is considered part of the system of system. - From a product requirement standpoint, MC-Review does not need to track changes on drafts (each edit a state makes to a draft data directly updates the original resource). ## Implementation -### The full copy of the submission data at a given point in time is called a revision. It is updated on submit. +### A full copy of the submission data at a given point in time is called a revision The change history audit log is a list of revisions sorted by data. This is currently stored in the `revisions` field on the Contract and Rate tables. A new revision is created each time a version of the form is submitted by states to CMS. - -## The link between contract and rates is versioned as well. It is updated on submit. - -It's possible to tell if a link has become outdated by refencing the `valid After` and `validUntil` fields on the join table between contract and rate revisions. The `validFrom` is set when a link is created (when a contract is submitted with a link to rates). At the point of creation, the `validUntil` is still null. - -Later, if the related contract is then unlocked and resubmitted with the linked rate removed, the `validUntil` will be set. The revision link is still kept in the change history. But the link itself will be considered outdated since the `validUntil` is in the past. - -*Dev Note* : If the `validUntil` is null we can assume the link between contract and rate is current. -### There is important metadata associated with a revision to track user actions. It is updated on submit and unlock. -The `unlockInfo` and `submitInfo` associated with that revision is important metadata. Specifically, only revisions that are unlocked or resubmitted have unlock info data. - -*Dev Note*: If a submission has undefined `unlockInfo``, we can assume two things 1. that revision is the latest submitted version 2. that revision is the first submission associated with that contract or rate. - +### There is important metadata associated with a revision to track user actions +The `unlockInfo` and `submitInfo` associated with that revision is important metadata. Specifically, revisions that have unlocked have unlock info data. If they do not have unlock info data, we can assume 1. that revision is the latest submitted version 2. that revision is the first submission associated with that contrac tor rate. TODO - [ ] add discussion `find*WithHistory` +- [ ] add discussion of `validAfter` and `validUntil` - [ ] add discussion `draftRevision` ## Related documentation From c1954cefe29a2686e2fc7cb62eae35067074888f Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Fri, 15 Sep 2023 12:53:45 -0700 Subject: [PATCH 10/23] start unlock work --- .../src/resolvers/configureResolvers.ts | 3 +- .../submitHealthPlanPackage.test.ts | 45 +- .../submitHealthPlanPackage.ts | 34 +- .../unlockHealthPlanPackage.test.ts | 1313 ++++++++++------- .../unlockHealthPlanPackage.ts | 319 ++-- 5 files changed, 987 insertions(+), 727 deletions(-) diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 13ce46419a..9213a11064 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -69,7 +69,8 @@ export function configureResolvers( unlockHealthPlanPackage: unlockHealthPlanPackageResolver( store, emailer, - emailParameterStore + emailParameterStore, + launchDarkly ), updateCMSUser: updateCMSUserResolver(store), createQuestion: createQuestionResolver(store), diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts index ec12589936..c0bec84f84 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.test.ts @@ -28,6 +28,7 @@ import type { FeatureFlagLDConstant, FlagValue, } from 'app-web/src/common-code/featureFlags' +import { testLDService } from '../../testHelpers/launchDarklyHelpers' const flagValueTestParameters: { flagName: FeatureFlagLDConstant @@ -50,8 +51,12 @@ describe.each(flagValueTestParameters)( `Tests $testName`, ({ flagName, flagValue }) => { const cmsUser = testCMSUser() + const mockLDService = testLDService({ [flagName]: flagValue }) + it('returns a StateSubmission if complete', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) // setup const initialPkg = await createAndUpdateTestHealthPlanPackage( @@ -130,7 +135,9 @@ describe.each(flagValueTestParameters)( }, 20000) it('returns an error if there are no contract documents attached', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) const draft = await createAndUpdateTestHealthPlanPackage(server, { documents: [], @@ -158,7 +165,9 @@ describe.each(flagValueTestParameters)( }) it('returns an error if the package is already SUBMITTED', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) const draft = await createAndSubmitTestHealthPlanPackage(server) const draftID = draft.id @@ -193,7 +202,9 @@ describe.each(flagValueTestParameters)( }) it('returns an error if there are no contract details fields', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) const draft = await createAndUpdateTestHealthPlanPackage(server, { contractType: undefined, @@ -223,7 +234,9 @@ describe.each(flagValueTestParameters)( }) it('returns an error if there are missing rate details fields for submission type', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) const draft = await createAndUpdateTestHealthPlanPackage(server, { submissionType: 'CONTRACT_AND_RATES', @@ -273,7 +286,9 @@ describe.each(flagValueTestParameters)( }) it('does not remove any rate data from CONTRACT_AND_RATES submissionType and submits successfully', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) //Create and update a contract and rate submission to contract only with rate data const draft = await createAndUpdateTestHealthPlanPackage(server, { @@ -338,7 +353,9 @@ describe.each(flagValueTestParameters)( }) it('removes any rate data from CONTRACT_ONLY submissionType and submits successfully', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) //Create and update a contract and rate submission to contract only with rate data const draft = await createAndUpdateTestHealthPlanPackage(server, { @@ -393,7 +410,9 @@ describe.each(flagValueTestParameters)( }) it('removes any invalid modified provisions from CHIP submission and submits successfully', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) //Create and update a submission as if the user edited and changed population covered after filling out yes/nos const draft = await createAndUpdateTestHealthPlanPackage(server, { @@ -454,7 +473,9 @@ describe.each(flagValueTestParameters)( }) it('removes any invalid federal authorities from CHIP submission and submits successfully', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) //Create and update a submission as if the user edited and changed population covered after filling out yes/nos const draft = await createAndUpdateTestHealthPlanPackage(server, { @@ -907,6 +928,8 @@ describe.each(flagValueTestParameters)( // expect sendEmail to have been called, so we know it did not error earlier expect(mockEmailer.sendEmail).toHaveBeenCalled() + jest.resetAllMocks() + // expect correct graphql error. expect(submitResult.errors?.[0]).toEqual( expect.objectContaining({ @@ -924,7 +947,9 @@ describe.each(flagValueTestParameters)( }) it('errors when risk based question is undefined', async () => { - const server = await constructTestPostgresServer() + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) // setup const initialPkg = await createAndUpdateTestHealthPlanPackage( diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index f7ddc9119f..4c729df552 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -316,8 +316,26 @@ export function submitHealthPlanPackageResolver( ) const submitRatesResult = await Promise.all(ratePromises) - if (isStoreError(submitRatesResult)) { - const errMessage = `Issue updating a package of type ${submitRatesResult.code}. Message: ${submitRatesResult.message}` + // if any of the promises reject, which shouldn't happen b/c we don't throw... + if (submitRatesResult instanceof Error) { + const errMessage = `Failed to submit contract revision's rates with ID: ${contractRevisionID}; ${submitRatesResult.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + const submitRateErrors: Error[] = submitRatesResult.filter( + (res) => res instanceof Error + ) as Error[] + if (submitRateErrors.length > 0) { + console.error('Errors submitting Rates: ', submitRateErrors) + const errMessage = `Failed to submit contract revision's rates with ID: ${contractRevisionID}; ${submitRateErrors.map( + (err) => err.message + )}` logError('submitHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new GraphQLError(errMessage, { @@ -326,10 +344,6 @@ export function submitHealthPlanPackageResolver( cause: 'DB_ERROR', }, }) - } else if (submitRatesResult instanceof Error) { - throw new Error( - 'Still to do - figuring out error handling and if this path is possible' - ) } } @@ -339,8 +353,8 @@ export function submitHealthPlanPackageResolver( submittedByUserID: user.id, submitReason: updateInfo.updatedReason, }) - if (isStoreError(submitContractResult)) { - const errMessage = `Issue updating a package of type ${submitContractResult.code}. Message: ${submitContractResult.message}` + if (submitContractResult instanceof Error) { + const errMessage = `Failed to submit contract revision with ID: ${contractRevisionID}; ${submitContractResult.message}` logError('submitHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new GraphQLError(errMessage, { @@ -349,10 +363,6 @@ export function submitHealthPlanPackageResolver( cause: 'DB_ERROR', }, }) - } else if (submitContractResult instanceof Error) { - throw new Error( - 'Still to do - figuring out error handling and if this path is possible' - ) } const maybeSubmittedPkg = convertContractWithRatesToUnlockedHPP(submitContractResult) diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts index cb2da0632e..69fe131ea8 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts @@ -27,616 +27,821 @@ import { mockEmailParameterStoreError, } from '../../testHelpers/parameterStoreHelpers' import { testCMSUser, testStateUser } from '../../testHelpers/userHelpers' +import type { + FeatureFlagLDConstant, + FlagValue, +} from 'app-web/src/common-code/featureFlags' +import { testLDService } from '../../testHelpers/launchDarklyHelpers' + +const flagValueTestParameters: { + flagName: FeatureFlagLDConstant + flagValue: FlagValue + testName: string +}[] = [ + { + flagName: 'rates-db-refactor', + flagValue: false, + testName: 'unlockHealthPlanPackage with all feature flags off', + }, + { + flagName: 'rates-db-refactor', + flagValue: true, + testName: 'unlockHealthPlanPackage with rates-db-refactor on', + }, +] + +describe.each(flagValueTestParameters)( + `Tests $testName`, + ({ flagName, flagValue }) => { + const cmsUser = testCMSUser() + const mockLDService = testLDService({ [flagName]: flagValue }) + + it('returns a HealthPlanPackage with all revisions', async () => { + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) -describe('unlockHealthPlanPackage', () => { - const cmsUser = testCMSUser() - it('returns a HealthPlanPackage with all revisions', async () => { - const stateServer = await constructTestPostgresServer() + // First, create a new submitted submission + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) - // First, create a new submitted submission - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + }) - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - }) + // Unlock + const unlockResult = await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + unlockedReason: 'Super duper good reason.', + }, + }, + }) - // Unlock - const unlockResult = await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - unlockedReason: 'Super duper good reason.', + expect(unlockResult.errors).toBeUndefined() + + if (!unlockResult?.data) { + throw new Error('this should never happen') + } + + const unlockedSub: HealthPlanPackage = + unlockResult.data.unlockHealthPlanPackage.pkg + + // After unlock, we should get a draft submission back + expect(unlockedSub.status).toBe('UNLOCKED') + + expect(unlockedSub.revisions).toHaveLength(2) + + expect(unlockedSub.revisions[0].node.submitInfo).toBeNull() + expect(unlockedSub.revisions[1].node.submitInfo).toBeDefined() + expect( + unlockedSub.revisions[1].node.submitInfo?.updatedAt.toISOString() + ).toContain(todaysDate()) + // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z + expect( + unlockedSub.revisions[1].node.submitInfo?.updatedAt.toISOString() + ).toContain('Z') + + expect(unlockedSub.revisions[0].node.unlockInfo).toBeDefined() + expect(unlockedSub.revisions[0].node.unlockInfo?.updatedBy).toBe( + 'zuko@example.com' + ) + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedReason + ).toBe('Super duper good reason.') + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() + ).toContain(todaysDate()) + // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() + ).toContain('Z') + }, 20000) + + it('returns a package that can be updated without errors', async () => { + const stateServer = await constructTestPostgresServer() + + // First, create a new submitted submission + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, }, - }, - }) + }) - expect(unlockResult.errors).toBeUndefined() - - if (!unlockResult?.data) { - throw new Error('this should never happen') - } - - const unlockedSub: HealthPlanPackage = - unlockResult.data.unlockHealthPlanPackage.pkg - - // After unlock, we should get a draft submission back - expect(unlockedSub.status).toBe('UNLOCKED') - - expect(unlockedSub.revisions).toHaveLength(2) - - expect(unlockedSub.revisions[0].node.submitInfo).toBeNull() - expect(unlockedSub.revisions[1].node.submitInfo).toBeDefined() - expect( - unlockedSub.revisions[1].node.submitInfo?.updatedAt.toISOString() - ).toContain(todaysDate()) - // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z - expect( - unlockedSub.revisions[1].node.submitInfo?.updatedAt.toISOString() - ).toContain('Z') - - expect(unlockedSub.revisions[0].node.unlockInfo).toBeDefined() - expect(unlockedSub.revisions[0].node.unlockInfo?.updatedBy).toBe( - 'zuko@example.com' - ) - expect(unlockedSub.revisions[0].node.unlockInfo?.updatedReason).toBe( - 'Super duper good reason.' - ) - expect( - unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() - ).toContain(todaysDate()) - // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z - expect( - unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() - ).toContain('Z') - }, 20000) - - it('returns a package that can be updated without errors', async () => { - const stateServer = await constructTestPostgresServer() - - // First, create a new submitted submission - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) - - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - }) + // Unlock + const unlockResult = await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + unlockedReason: 'Super duper good reason.', + }, + }, + }) - // Unlock - const unlockResult = await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - unlockedReason: 'Super duper good reason.', + expect(unlockResult.errors).toBeUndefined() + const unlockedSub = unlockResult?.data?.unlockHealthPlanPackage.pkg + + // After unlock, we should get a draft submission back + expect(unlockedSub.status).toBe('UNLOCKED') + expect(unlockedSub.revisions[0].node.unlockInfo).toBeDefined() + expect(unlockedSub.revisions[0].node.unlockInfo?.updatedBy).toBe( + 'zuko@example.com' + ) + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedReason + ).toBe('Super duper good reason.') + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() + ).toContain(todaysDate()) + // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() + ).toContain('Z') + + const formData = latestFormData(unlockedSub) + + // after unlock we should be able to update that draft submission and get the results + formData.programIDs = [defaultFloridaProgram().id] + formData.submissionType = 'CONTRACT_AND_RATES' as const + formData.submissionDescription = 'UPDATED_AFTER_UNLOCK' + formData.documents = [] + formData.contractType = 'BASE' as const + formData.contractDocuments = [] + formData.managedCareEntities = ['MCO'] + formData.federalAuthorities = ['VOLUNTARY' as const] + formData.stateContacts = [] + formData.addtlActuaryContacts = [] + + await updateTestHealthPlanFormData(stateServer, formData) + + const refetched = await fetchTestHealthPlanPackageById( + stateServer, + stateSubmission.id + ) + + const refetchedFormData = latestFormData(refetched) + + expect(refetchedFormData.submissionDescription).toBe( + 'UPDATED_AFTER_UNLOCK' + ) + }, 20000) + + it('allows for multiple edits, editing the set of revisions correctly', async () => { + const stateServer = await constructTestPostgresServer() + + // First, create a new submitted submission + const stateDraft = await createAndSubmitTestHealthPlanPackage( + stateServer + // unlockedWithFullRates(), + ) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, }, - }, - }) + }) - expect(unlockResult.errors).toBeUndefined() - const unlockedSub = unlockResult?.data?.unlockHealthPlanPackage.pkg - - // After unlock, we should get a draft submission back - expect(unlockedSub.status).toBe('UNLOCKED') - expect(unlockedSub.revisions[0].node.unlockInfo).toBeDefined() - expect(unlockedSub.revisions[0].node.unlockInfo?.updatedBy).toBe( - 'zuko@example.com' - ) - expect(unlockedSub.revisions[0].node.unlockInfo?.updatedReason).toBe( - 'Super duper good reason.' - ) - expect( - unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() - ).toContain(todaysDate()) - // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z - expect( - unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() - ).toContain('Z') - - const formData = latestFormData(unlockedSub) - - // after unlock we should be able to update that draft submission and get the results - formData.programIDs = [defaultFloridaProgram().id] - formData.submissionType = 'CONTRACT_AND_RATES' as const - formData.submissionDescription = 'UPDATED_AFTER_UNLOCK' - formData.documents = [] - formData.contractType = 'BASE' as const - formData.contractDocuments = [] - formData.managedCareEntities = ['MCO'] - formData.federalAuthorities = ['VOLUNTARY' as const] - formData.stateContacts = [] - formData.addtlActuaryContacts = [] - - await updateTestHealthPlanFormData(stateServer, formData) - - const refetched = await fetchTestHealthPlanPackageById( - stateServer, - stateSubmission.id - ) - - const refetchedFormData = latestFormData(refetched) - - expect(refetchedFormData.submissionDescription).toBe( - 'UPDATED_AFTER_UNLOCK' - ) - }, 20000) - - it('can be unlocked repeatedly', async () => { - const stateServer = await constructTestPostgresServer() - - // First, create a new submitted submission - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) - - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - }) + // Unlock + const unlockResult = await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateDraft.id, + unlockedReason: 'Super duper good reason.', + }, + }, + }) - await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Super duper good reason.' - ) - - await resubmitTestHealthPlanPackage( - stateServer, - stateSubmission.id, - 'Test first resubmission reason' - ) - - await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Super duper duper good reason.' - ) - - await resubmitTestHealthPlanPackage( - stateServer, - stateSubmission.id, - 'Test second resubmission reason' - ) - - const draft = await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Very super duper good reason.' - ) - expect(draft.status).toBe('UNLOCKED') - expect(draft.revisions[0].node.unlockInfo?.updatedBy).toBe( - 'zuko@example.com' - ) - expect(draft.revisions[0].node.unlockInfo?.updatedReason).toBe( - 'Very super duper good reason.' - ) - expect( - draft.revisions[0].node.unlockInfo?.updatedAt.toISOString() - ).toContain(todaysDate()) - // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z - expect( - draft.revisions[0].node.unlockInfo?.updatedAt.toISOString() - ).toContain('Z') - }, 20000) - - it.todo( - 'returns package where previously linked documents and contacts can be deleted without breaking old revisions' - ) // this can be completed after unlock - want to create, submit, unlock, then re-edit - - it('returns errors if a state user tries to unlock', async () => { - const stateServer = await constructTestPostgresServer() - - // First, create a new submitted submission - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) - - // Unlock - const unlockResult = await stateServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - unlockedReason: 'Super duper good reason.', + expect(unlockResult.errors).toBeUndefined() + const unlockedSub = unlockResult?.data?.unlockHealthPlanPackage.pkg + + // After unlock, we should get a draft submission back + expect(unlockedSub.status).toBe('UNLOCKED') + expect(unlockedSub.revisions[0].node.unlockInfo).toBeDefined() + expect(unlockedSub.revisions[0].node.unlockInfo?.updatedBy).toBe( + 'zuko@example.com' + ) + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedReason + ).toBe('Super duper good reason.') + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() + ).toContain(todaysDate()) + // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z + expect( + unlockedSub.revisions[0].node.unlockInfo?.updatedAt.toISOString() + ).toContain('Z') + + const formData = latestFormData(unlockedSub) + + // after unlock we should be able to update that draft submission and get the results + formData.submissionDescription = 'UPDATED_AFTER_UNLOCK' + + formData.rateInfos.push( + { + rateDateStart: new Date(), + rateDateEnd: new Date(), + rateProgramIDs: ['5c10fe9f-bec9-416f-a20c-718b152ad633'], + rateType: 'NEW', + rateDateCertified: new Date(), + rateDocuments: [ + { + name: 'fake doc', + s3URL: 'foo://bar', + documentCategories: ['RATES'], + }, + ], + supportingDocuments: [], + actuaryContacts: [ + { + name: 'Enrico Soletzo', + titleRole: 'person', + email: 'en@example.com', + actuarialFirm: 'MERCER', + }, + ], }, - }, - }) + { + rateDateStart: new Date(), + rateDateEnd: new Date(), + rateProgramIDs: ['5c10fe9f-bec9-416f-a20c-718b152ad633'], + rateType: 'NEW', + rateDateCertified: new Date(), + rateDocuments: [ + { + name: 'fake doc number two', + s3URL: 'foo://bar', + documentCategories: ['RATES'], + }, + ], + supportingDocuments: [], + actuaryContacts: [ + { + name: 'Enrico Soletzo', + titleRole: 'person', + email: 'en@example.com', + actuarialFirm: 'MERCER', + }, + ], + } + ) + + await updateTestHealthPlanFormData(stateServer, formData) + + const refetched = await fetchTestHealthPlanPackageById( + stateServer, + stateDraft.id + ) + + const refetchedFormData = latestFormData(refetched) + + expect(refetchedFormData.submissionDescription).toBe( + 'UPDATED_AFTER_UNLOCK' + ) + + expect(refetchedFormData.rateInfos).toHaveLength(3) + + const rateDocs = refetchedFormData.rateInfos.map( + (r) => r.rateDocuments[0].name + ) + expect(rateDocs).toEqual([ + 'rateDocument.pdf', + 'fake doc', + 'fake doc number two', + ]) + + await resubmitTestHealthPlanPackage( + stateServer, + stateDraft.id, + 'Test first resubmission reason' + ) + + const unlockedPKG = await unlockTestHealthPlanPackage( + cmsServer, + stateDraft.id, + 'unlock to remove rate' + ) + + const unlockedFormData = latestFormData(unlockedPKG) + + // remove the first rate + unlockedFormData.rateInfos = unlockedFormData.rateInfos.slice(1) + + await updateTestHealthPlanFormData(stateServer, unlockedFormData) + + const finallySubmittedPKG = await resubmitTestHealthPlanPackage( + stateServer, + stateDraft.id, + 'Test second resubmission reason' + ) + + const finallySubmittedFormData = latestFormData(finallySubmittedPKG) + + expect(finallySubmittedFormData.rateInfos).toHaveLength(2) + const finalRateDocs = finallySubmittedFormData.rateInfos.map( + (r) => r.rateDocuments[0].name + ) + expect(finalRateDocs).toEqual(['fake doc', 'fake doc number two']) + + throw new Error('Not done with this test yet') + }, 20000) + + it('can be unlocked repeatedly', async () => { + const stateServer = await constructTestPostgresServer() + + // First, create a new submitted submission + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + }) - expect(unlockResult.errors).toBeDefined() - const err = (unlockResult.errors as GraphQLError[])[0] + await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Super duper good reason.' + ) + + await resubmitTestHealthPlanPackage( + stateServer, + stateSubmission.id, + 'Test first resubmission reason' + ) + + await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Super duper duper good reason.' + ) + + await resubmitTestHealthPlanPackage( + stateServer, + stateSubmission.id, + 'Test second resubmission reason' + ) + + const draft = await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Very super duper good reason.' + ) + expect(draft.status).toBe('UNLOCKED') + expect(draft.revisions[0].node.unlockInfo?.updatedBy).toBe( + 'zuko@example.com' + ) + expect(draft.revisions[0].node.unlockInfo?.updatedReason).toBe( + 'Very super duper good reason.' + ) + expect( + draft.revisions[0].node.unlockInfo?.updatedAt.toISOString() + ).toContain(todaysDate()) + // check that the date has full ISO time eg. 2022-03-25T03:09:54.864Z + expect( + draft.revisions[0].node.unlockInfo?.updatedAt.toISOString() + ).toContain('Z') + }, 20000) + + it.todo( + 'returns package where previously linked documents and contacts can be deleted without breaking old revisions' + ) // this can be completed after unlock - want to create, submit, unlock, then re-edit + + it('returns errors if a state user tries to unlock', async () => { + const stateServer = await constructTestPostgresServer() + + // First, create a new submitted submission + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + // Unlock + const unlockResult = await stateServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + unlockedReason: 'Super duper good reason.', + }, + }, + }) - expect(err.extensions['code']).toBe('FORBIDDEN') - expect(err.message).toBe('user not authorized to unlock package') - }) + expect(unlockResult.errors).toBeDefined() + const err = (unlockResult.errors as GraphQLError[])[0] - it('returns errors if trying to unlock package with wrong package status', async () => { - const stateServer = await constructTestPostgresServer() - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, + expect(err.extensions['code']).toBe('FORBIDDEN') + expect(err.message).toBe('user not authorized to unlock package') }) - // First, create a new draft submission - const stateSubmission = await createAndUpdateTestHealthPlanPackage( - stateServer - ) - - // Attempt Unlock Draft - const unlockDraftResult = await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - unlockedReason: 'Super duper good reason.', + it('returns errors if trying to unlock package with wrong package status', async () => { + const stateServer = await constructTestPostgresServer() + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, }, - }, - }) + }) - expect(unlockDraftResult.errors).toBeDefined() - const err = (unlockDraftResult.errors as GraphQLError[])[0] - - expect(err.extensions).toEqual( - expect.objectContaining({ - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - exception: { - locations: undefined, - message: 'Attempted to unlock package with wrong status', - path: undefined, + // First, create a new draft submission + const stateSubmission = await createAndUpdateTestHealthPlanPackage( + stateServer + ) + + // Attempt Unlock Draft + const unlockDraftResult = await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + unlockedReason: 'Super duper good reason.', + }, }, }) - ) - expect(err.message).toBe( - 'Attempted to unlock package with wrong status' - ) - - await submitTestHealthPlanPackage(stateServer, stateSubmission.id) - - // Unlock Submission - await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Super duper good reason.' - ) - - // Attempt Unlock Unlocked - const unlockUnlockedResult = await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - unlockedReason: 'Super duper good reason.', - }, - }, - }) - expect(unlockUnlockedResult.errors).toBeDefined() - const unlockErr = (unlockUnlockedResult.errors as GraphQLError[])[0] - - expect(unlockErr.extensions).toEqual( - expect.objectContaining({ - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - exception: { - locations: undefined, - message: 'Attempted to unlock package with wrong status', - path: undefined, + expect(unlockDraftResult.errors).toBeDefined() + const err = (unlockDraftResult.errors as GraphQLError[])[0] + + expect(err.extensions).toEqual( + expect.objectContaining({ + code: 'INTERNAL_SERVER_ERROR', + cause: 'INVALID_PACKAGE_STATUS', + exception: { + locations: undefined, + message: + 'Attempted to unlock package with wrong status', + path: undefined, + }, + }) + ) + expect(err.message).toBe( + 'Attempted to unlock package with wrong status' + ) + + await submitTestHealthPlanPackage(stateServer, stateSubmission.id) + + // Unlock Submission + await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Super duper good reason.' + ) + + // Attempt Unlock Unlocked + const unlockUnlockedResult = await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + unlockedReason: 'Super duper good reason.', + }, }, }) - ) - expect(unlockErr.message).toBe( - 'Attempted to unlock package with wrong status' - ) - }) - - it('returns an error if the submission does not exit', async () => { - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, + + expect(unlockUnlockedResult.errors).toBeDefined() + const unlockErr = (unlockUnlockedResult.errors as GraphQLError[])[0] + + expect(unlockErr.extensions).toEqual( + expect.objectContaining({ + code: 'INTERNAL_SERVER_ERROR', + cause: 'INVALID_PACKAGE_STATUS', + exception: { + locations: undefined, + message: + 'Attempted to unlock package with wrong status', + path: undefined, + }, + }) + ) + expect(unlockErr.message).toBe( + 'Attempted to unlock package with wrong status' + ) }) - // First, create a new submitted submission - // const stateSubmission = await createAndSubmitTestHealthPlanPackage(stateServer) + it('returns an error if the submission does not exit', async () => { + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + }) - // Unlock - const unlockResult = await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: 'foo-bar', - unlockedReason: 'Super duper good reason.', + // First, create a new submitted submission + // const stateSubmission = await createAndSubmitTestHealthPlanPackage(stateServer) + + // Unlock + const unlockResult = await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: 'foo-bar', + unlockedReason: 'Super duper good reason.', + }, }, - }, - }) + }) - expect(unlockResult.errors).toBeDefined() - const err = (unlockResult.errors as GraphQLError[])[0] + expect(unlockResult.errors).toBeDefined() + const err = (unlockResult.errors as GraphQLError[])[0] - expect(err.extensions['code']).toBe('BAD_USER_INPUT') - expect(err.message).toBe('A package must exist to be unlocked: foo-bar') - }) + expect(err.extensions['code']).toBe('BAD_USER_INPUT') + expect(err.message).toBe( + 'A package must exist to be unlocked: foo-bar' + ) + }) - it('returns an error if the DB errors', async () => { - const errorStore = mockStoreThatErrors() + it('returns an error if the DB errors', async () => { + const errorStore = mockStoreThatErrors() - const cmsServer = await constructTestPostgresServer({ - store: errorStore, - context: { - user: cmsUser, - }, - }) + const cmsServer = await constructTestPostgresServer({ + store: errorStore, + context: { + user: cmsUser, + }, + }) - // Unlock - const unlockResult = await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: 'foo-bar', - unlockedReason: 'Super duper good reason.', + // Unlock + const unlockResult = await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: 'foo-bar', + unlockedReason: 'Super duper good reason.', + }, }, - }, + }) + + expect(unlockResult.errors).toBeDefined() + const err = (unlockResult.errors as GraphQLError[])[0] + + expect(err.extensions['code']).toBe('INTERNAL_SERVER_ERROR') + expect(err.message).toBe( + 'Issue finding a package of type UNEXPECTED_EXCEPTION. Message: this error came from the generic store with errors mock' + ) }) - expect(unlockResult.errors).toBeDefined() - const err = (unlockResult.errors as GraphQLError[])[0] + it('returns errors if unlocked reason is undefined', async () => { + const stateServer = await constructTestPostgresServer() - expect(err.extensions['code']).toBe('INTERNAL_SERVER_ERROR') - expect(err.message).toBe( - 'Issue finding a package of type UNEXPECTED_EXCEPTION. Message: this error came from the generic store with errors mock' - ) - }) + // First, create a new submitted submission + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) - it('returns errors if unlocked reason is undefined', async () => { - const stateServer = await constructTestPostgresServer() + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + }) + + // Attempt Unlock Draft + const unlockedResult = await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + unlockedReason: undefined, + }, + }, + }) - // First, create a new submitted submission - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) + expect(unlockedResult.errors).toBeDefined() + const err = (unlockedResult.errors as GraphQLError[])[0] - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, + expect(err.extensions['code']).toBe('BAD_USER_INPUT') + expect(err.message).toContain( + 'Field "unlockedReason" of required type "String!" was not provided.' + ) }) - // Attempt Unlock Draft - const unlockedResult = await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - unlockedReason: undefined, + it('send email to CMS when unlocking submission succeeds', async () => { + const config = testEmailConfig() + const mockEmailer = testEmailer(config) + //mock invoke email submit lambda + const stateServer = await constructTestPostgresServer() + + // First, create a new submitted submission + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, }, - }, - }) + emailer: mockEmailer, + }) - expect(unlockedResult.errors).toBeDefined() - const err = (unlockedResult.errors as GraphQLError[])[0] - - expect(err.extensions['code']).toBe('BAD_USER_INPUT') - expect(err.message).toContain( - 'Field "unlockedReason" of required type "String!" was not provided.' - ) - }) - - it('send email to CMS when unlocking submission succeeds', async () => { - const config = testEmailConfig() - const mockEmailer = testEmailer(config) - //mock invoke email submit lambda - const stateServer = await constructTestPostgresServer() - - // First, create a new submitted submission - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) - - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - emailer: mockEmailer, + // Unlock + const unlockResult = await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Super duper good reason.' + ) + + const currentRevision = unlockResult.revisions[0].node.formDataProto + + const sub = base64ToDomain(currentRevision) + if (sub instanceof Error) { + throw sub + } + + const programs = [defaultFloridaProgram()] + const ratePrograms = [defaultFloridaRateProgram()] + const name = packageName(sub, programs) + const rateName = generateRateName( + sub, + sub.rateInfos[0], + ratePrograms + ) + const stateAnalystsEmails = getTestStateAnalystsEmails( + sub.stateCode + ) + + const cmsEmails = [ + ...config.devReviewTeamEmails, + ...stateAnalystsEmails, + ...config.oactEmails, + ] + + // email subject line is correct for CMS email + expect(mockEmailer.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: expect.stringContaining(`${name} was unlocked`), + sourceEmail: config.emailSource, + toAddresses: expect.arrayContaining(Array.from(cmsEmails)), + bodyHTML: expect.stringContaining(rateName), + }) + ) }) - // Unlock - const unlockResult = await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Super duper good reason.' - ) - - const currentRevision = unlockResult.revisions[0].node.formDataProto - - const sub = base64ToDomain(currentRevision) - if (sub instanceof Error) { - throw sub - } - - const programs = [defaultFloridaProgram()] - const ratePrograms = [defaultFloridaRateProgram()] - const name = packageName(sub, programs) - const rateName = generateRateName(sub, sub.rateInfos[0], ratePrograms) - const stateAnalystsEmails = getTestStateAnalystsEmails(sub.stateCode) - - const cmsEmails = [ - ...config.devReviewTeamEmails, - ...stateAnalystsEmails, - ...config.oactEmails, - ] - - // email subject line is correct for CMS email - expect(mockEmailer.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: expect.stringContaining(`${name} was unlocked`), - sourceEmail: config.emailSource, - toAddresses: expect.arrayContaining(Array.from(cmsEmails)), - bodyHTML: expect.stringContaining(rateName), + 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() + const stateServerTwo = await constructTestPostgresServer({ + context: { + user: testStateUser({ + email: 'notspiderman@example.com', + }), + }, }) - ) - }) - - 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() - const stateServerTwo = await constructTestPostgresServer({ - context: { - user: testStateUser({ - email: 'notspiderman@example.com', - }), - }, - }) - // First, create a new submitted submission - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) + // First, create a new submitted submission + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + emailer: mockEmailer, + }) - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - emailer: mockEmailer, + // First unlock + await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Super duper good reason.' + ) + + // Resubmission to get multiple submitters + await resubmitTestHealthPlanPackage( + stateServerTwo, + stateSubmission.id, + 'Test resubmission reason' + ) + + // Final unlock to test against + const unlockResult = await unlockTestHealthPlanPackage( + cmsServer, + stateSubmission.id, + 'Super duper good reason.' + ) + + const currentRevision = unlockResult.revisions[0].node.formDataProto + + const sub = base64ToDomain(currentRevision) + if (sub instanceof Error) { + throw sub + } + + const programs = [defaultFloridaProgram()] + const ratePrograms = [defaultFloridaRateProgram()] + const name = packageName(sub, programs) + const rateName = generateRateName( + sub, + sub.rateInfos[0], + ratePrograms + ) + + const stateReceiverEmails = [ + 'james@example.com', + 'notspiderman@example.com', + ...sub.stateContacts.map((contact) => contact.email), + ] + + // email subject line is correct for CMS email. + // Mock emailer is called 4 times, 2 for the first unlock, 2 for the second unlock. + // From the pair of emails, the second one is the state email. + expect(mockEmailer.sendEmail).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + subject: expect.stringContaining( + `${name} was unlocked by CMS` + ), + sourceEmail: config.emailSource, + toAddresses: expect.arrayContaining( + Array.from(stateReceiverEmails) + ), + bodyHTML: expect.stringContaining(rateName), + }) + ) }) - // First unlock - await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Super duper good reason.' - ) - - // Resubmission to get multiple submitters - await resubmitTestHealthPlanPackage( - stateServerTwo, - stateSubmission.id, - 'Test resubmission reason' - ) - - // Final unlock to test against - const unlockResult = await unlockTestHealthPlanPackage( - cmsServer, - stateSubmission.id, - 'Super duper good reason.' - ) - - const currentRevision = unlockResult.revisions[0].node.formDataProto - - const sub = base64ToDomain(currentRevision) - if (sub instanceof Error) { - throw sub - } - - const programs = [defaultFloridaProgram()] - const ratePrograms = [defaultFloridaRateProgram()] - const name = packageName(sub, programs) - const rateName = generateRateName(sub, sub.rateInfos[0], ratePrograms) - - const stateReceiverEmails = [ - 'james@example.com', - 'notspiderman@example.com', - ...sub.stateContacts.map((contact) => contact.email), - ] - - // email subject line is correct for CMS email. - // Mock emailer is called 4 times, 2 for the first unlock, 2 for the second unlock. - // From the pair of emails, the second one is the state email. - expect(mockEmailer.sendEmail).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - subject: expect.stringContaining(`${name} was unlocked by CMS`), - sourceEmail: config.emailSource, - toAddresses: expect.arrayContaining( - Array.from(stateReceiverEmails) - ), - bodyHTML: expect.stringContaining(rateName), + it('does send unlock email when request for state analysts emails fails', async () => { + const config = testEmailConfig() + const mockEmailer = testEmailer(config) + //mock invoke email submit lambda + const mockEmailParameterStore = mockEmailParameterStoreError() + const stateServer = await constructTestPostgresServer() + + // First, create a new submitted submission + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + emailer: mockEmailer, + emailParameterStore: mockEmailParameterStore, }) - ) - }) - - it('does send unlock email when request for state analysts emails fails', async () => { - const config = testEmailConfig() - const mockEmailer = testEmailer(config) - //mock invoke email submit lambda - const mockEmailParameterStore = mockEmailParameterStoreError() - const stateServer = await constructTestPostgresServer() - - // First, create a new submitted submission - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) - - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - emailer: mockEmailer, - emailParameterStore: mockEmailParameterStore, - }) - // Unlock - await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - unlockedReason: 'Super duper good reason.', + // Unlock + await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + unlockedReason: 'Super duper good reason.', + }, }, - }, + }) + + expect(mockEmailer.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + toAddresses: expect.arrayContaining( + Array.from(config.devReviewTeamEmails) + ), + }) + ) }) - expect(mockEmailer.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - toAddresses: expect.arrayContaining( - Array.from(config.devReviewTeamEmails) - ), + it('does log error when request for state specific analysts emails failed', async () => { + const mockEmailParameterStore = mockEmailParameterStoreError() + const consoleErrorSpy = jest.spyOn(console, 'error') + const stateServer = await constructTestPostgresServer() + const error = { + error: 'No store found', + message: 'getStateAnalystsEmails failed', + operation: 'getStateAnalystsEmails', + status: 'ERROR', + } + + const stateSubmission = await createAndSubmitTestHealthPlanPackage( + stateServer + ) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + emailParameterStore: mockEmailParameterStore, }) - ) - }) - - it('does log error when request for state specific analysts emails failed', async () => { - const mockEmailParameterStore = mockEmailParameterStoreError() - const consoleErrorSpy = jest.spyOn(console, 'error') - const stateServer = await constructTestPostgresServer() - const error = { - error: 'No store found', - message: 'getStateAnalystsEmails failed', - operation: 'getStateAnalystsEmails', - status: 'ERROR', - } - - const stateSubmission = await createAndSubmitTestHealthPlanPackage( - stateServer - ) - - const cmsServer = await constructTestPostgresServer({ - context: { - user: cmsUser, - }, - emailParameterStore: mockEmailParameterStore, - }) - await cmsServer.executeOperation({ - query: UNLOCK_HEALTH_PLAN_PACKAGE, - variables: { - input: { - pkgID: stateSubmission.id, - unlockedReason: 'Super duper good reason.', + await cmsServer.executeOperation({ + query: UNLOCK_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: stateSubmission.id, + unlockedReason: 'Super duper good reason.', + }, }, - }, - }) + }) - expect(consoleErrorSpy).toHaveBeenCalledWith(error) - }) -}) + expect(consoleErrorSpy).toHaveBeenCalledWith(error) + }) + } +) diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts index 767817f1d7..39e664e0e7 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts @@ -22,6 +22,7 @@ import { } from '../attributeHelper' import type { EmailParameterStore } from '../../parameterStore' import { GraphQLError } from 'graphql' +import type { LDService } from '../../launchDarkly/launchDarkly' // unlock is a state machine transforming a LockedFormData and turning it into UnlockedFormData // Since Unlocked is a strict subset of Locked, this can't error today. @@ -42,9 +43,15 @@ function unlock( export function unlockHealthPlanPackageResolver( store: Store, emailer: Emailer, - emailParameterStore: EmailParameterStore + emailParameterStore: EmailParameterStore, + launchDarkly: LDService ): MutationResolvers['unlockHealthPlanPackage'] { return async (_parent, { input }, context) => { + const ratesDatabaseRefactor = await launchDarkly.getFeatureFlag( + context, + 'rates-db-refactor' + ) + const { user, span } = context const { unlockedReason, pkgID } = input setResolverDetailsOnActiveSpan('unlockHealthPlanPackage', user, span) @@ -63,174 +70,186 @@ export function unlockHealthPlanPackageResolver( throw new ForbiddenError('user not authorized to unlock package') } - // fetch from the store - const result = await store.findHealthPlanPackage(pkgID) - - if (isStoreError(result)) { - const errMessage = `Issue finding a package of type ${result.code}. Message: ${result.message}` - logError('unlockHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } - - if (result === undefined) { - const errMessage = `A package must exist to be unlocked: ${pkgID}` - logError('unlockHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new UserInputError(errMessage, { - argumentName: 'pkgID', - }) - } - - const pkg: HealthPlanPackageType = result - const pkgStatus = packageStatus(pkg) - const currentRevision = pkg.revisions[0] - - // Check that the package is in an unlockable state - if (pkgStatus === 'UNLOCKED' || pkgStatus === 'DRAFT') { - const errMessage = 'Attempted to unlock package with wrong status' - logError('unlockHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - }, - }) - } - - // pull the current revision out to unlock it. - const formDataResult = toDomain(currentRevision.formDataProto) - if (formDataResult instanceof Error) { - const errMessage = `Failed to decode proto ${formDataResult}.` - logError('unlockHealthPlanPackage', errMessage) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'PROTO_DECODE_ERROR', - }, - }) - } + if (ratesDatabaseRefactor) { + throw new Error('Not Implemented') + } else { + // pre-rates refactor code path + + // fetch from the store + const result = await store.findHealthPlanPackage(pkgID) + + if (isStoreError(result)) { + const errMessage = `Issue finding a package of type ${result.code}. Message: ${result.message}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } - if (formDataResult.status !== 'SUBMITTED') { - const errMessage = `A locked package had unlocked formData.` - logError('unlockHealthPlanPackage', errMessage) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - }, - }) - } + if (result === undefined) { + const errMessage = `A package must exist to be unlocked: ${pkgID}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage, { + argumentName: 'pkgID', + }) + } - const draftformData: UnlockedHealthPlanFormDataType = - unlock(formDataResult) + const pkg: HealthPlanPackageType = result + const pkgStatus = packageStatus(pkg) + const currentRevision = pkg.revisions[0] + + // Check that the package is in an unlockable state + if (pkgStatus === 'UNLOCKED' || pkgStatus === 'DRAFT') { + const errMessage = + 'Attempted to unlock package with wrong status' + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'INVALID_PACKAGE_STATUS', + }, + }) + } - // Create a new revision with this draft in it - const updateInfo: UpdateInfoType = { - updatedAt: new Date(), - updatedBy: context.user.email, - updatedReason: unlockedReason, - } + // pull the current revision out to unlock it. + const formDataResult = toDomain(currentRevision.formDataProto) + if (formDataResult instanceof Error) { + const errMessage = `Failed to decode proto ${formDataResult}.` + logError('unlockHealthPlanPackage', errMessage) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'PROTO_DECODE_ERROR', + }, + }) + } - const unlockedPackage = await store.insertHealthPlanRevision( - pkgID, - updateInfo, - draftformData - ) + if (formDataResult.status !== 'SUBMITTED') { + const errMessage = `A locked package had unlocked formData.` + logError('unlockHealthPlanPackage', errMessage) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'INVALID_PACKAGE_STATUS', + }, + }) + } - if (isStoreError(unlockedPackage)) { - const errMessage = `Issue unlocking a package of type ${unlockedPackage.code}. Message: ${unlockedPackage.message}` - logError('unlockHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } + const draftformData: UnlockedHealthPlanFormDataType = + unlock(formDataResult) - // Send emails! + // Create a new revision with this draft in it + const updateInfo: UpdateInfoType = { + updatedAt: new Date(), + updatedBy: context.user.email, + updatedReason: unlockedReason, + } - // Get state analysts emails from parameter store - let stateAnalystsEmails = - await emailParameterStore.getStateAnalystsEmails( - draftformData.stateCode + const unlockedPackage = await store.insertHealthPlanRevision( + pkgID, + updateInfo, + draftformData ) - //If error, log it and set stateAnalystsEmails to empty string as to not interrupt the emails. - if (stateAnalystsEmails instanceof Error) { - logError('getStateAnalystsEmails', stateAnalystsEmails.message) - setErrorAttributesOnActiveSpan(stateAnalystsEmails.message, span) - stateAnalystsEmails = [] - } - // Get submitter email from every pkg submitted revision. - const submitterEmails = packageSubmitters(unlockedPackage) + if (isStoreError(unlockedPackage)) { + const errMessage = `Issue unlocking a package of type ${unlockedPackage.code}. Message: ${unlockedPackage.message}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } - const statePrograms = store.findStatePrograms(draftformData.stateCode) + // Send emails! - 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', - }, - }) - } + // Get state analysts emails from parameter store + let stateAnalystsEmails = + await emailParameterStore.getStateAnalystsEmails( + draftformData.stateCode + ) + //If error, log it and set stateAnalystsEmails to empty string as to not interrupt the emails. + if (stateAnalystsEmails instanceof Error) { + logError('getStateAnalystsEmails', stateAnalystsEmails.message) + setErrorAttributesOnActiveSpan( + stateAnalystsEmails.message, + span + ) + stateAnalystsEmails = [] + } - const unlockPackageCMSEmailResult = - await emailer.sendUnlockPackageCMSEmail( - draftformData, - updateInfo, - stateAnalystsEmails, - statePrograms - ) + // Get submitter email from every pkg submitted revision. + const submitterEmails = packageSubmitters(unlockedPackage) - const unlockPackageStateEmailResult = - await emailer.sendUnlockPackageStateEmail( - draftformData, - updateInfo, - statePrograms, - submitterEmails + const statePrograms = store.findStatePrograms( + draftformData.stateCode ) - if ( - unlockPackageCMSEmailResult instanceof Error || - unlockPackageStateEmailResult instanceof Error - ) { - if (unlockPackageCMSEmailResult instanceof Error) { - logError( - 'unlockPackageCMSEmail - CMS email failed', - unlockPackageCMSEmailResult - ) - setErrorAttributesOnActiveSpan('CMS email failed', span) + 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', + }, + }) } - if (unlockPackageStateEmailResult instanceof Error) { - logError( - 'unlockPackageStateEmail - state email failed', - unlockPackageStateEmailResult + + const unlockPackageCMSEmailResult = + await emailer.sendUnlockPackageCMSEmail( + draftformData, + updateInfo, + stateAnalystsEmails, + statePrograms ) - setErrorAttributesOnActiveSpan('state email failed', span) + + const unlockPackageStateEmailResult = + await emailer.sendUnlockPackageStateEmail( + draftformData, + updateInfo, + statePrograms, + submitterEmails + ) + + if ( + unlockPackageCMSEmailResult instanceof Error || + unlockPackageStateEmailResult instanceof Error + ) { + if (unlockPackageCMSEmailResult instanceof Error) { + logError( + 'unlockPackageCMSEmail - CMS email failed', + unlockPackageCMSEmailResult + ) + setErrorAttributesOnActiveSpan('CMS email failed', span) + } + if (unlockPackageStateEmailResult instanceof Error) { + logError( + 'unlockPackageStateEmail - state email failed', + unlockPackageStateEmailResult + ) + setErrorAttributesOnActiveSpan('state email failed', span) + } + throw new GraphQLError('Email failed.', { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'EMAIL_ERROR', + }, + }) } - throw new GraphQLError('Email failed.', { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'EMAIL_ERROR', - }, - }) - } - logSuccess('unlockHealthPlanPackage') - setSuccessAttributesOnActiveSpan(span) + logSuccess('unlockHealthPlanPackage') + setSuccessAttributesOnActiveSpan(span) - return { pkg: unlockedPackage } + return { pkg: unlockedPackage } + } } } From 193abf2643997206fc7b70464fc0ffd0ed0c8695 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Fri, 22 Sep 2023 01:48:00 -0700 Subject: [PATCH 11/23] more shas --- services/app-api/src/handlers/add_sha.test.ts | 39 ------------------- .../handlers/migrate_rate_documents.test.ts | 9 +++++ 2 files changed, 9 insertions(+), 39 deletions(-) diff --git a/services/app-api/src/handlers/add_sha.test.ts b/services/app-api/src/handlers/add_sha.test.ts index 2737316eba..48442809a9 100644 --- a/services/app-api/src/handlers/add_sha.test.ts +++ b/services/app-api/src/handlers/add_sha.test.ts @@ -57,45 +57,6 @@ describe('add_sha', () => { ] } - it('should add a sha256 property to documents when it is missing', async () => { - // Create a revision with a missing sha256 property in the document - const revisions: HealthPlanRevisionTable[] = createRevisions([ - { - s3URL: 's3://bucketname/key/foo.png', - name: 'contract doc', - documentCategories: ['CONTRACT_RELATED'], - // Omit the sha256 property - }, - ]) - - const storeFindAllRevisionsSpy = jest.spyOn( - mockStore, - 'findAllRevisions' - ) - storeFindAllRevisionsSpy.mockResolvedValue(revisions) - - const updateHealthPlanRevisionSpy = jest.spyOn( - mockStore, - 'updateHealthPlanRevision' - ) - - await main({} as Event, {} as Context, () => { - /*empty callback*/ - }) - - // the sha should be set to the mock value defined above - expect(updateHealthPlanRevisionSpy).toHaveBeenCalledWith( - 'mockPkgID', - 'mockId', - expect.objectContaining({ - documents: expect.arrayContaining([ - expect.objectContaining({ - sha256: 'mockSHA256', - }), - ]), - }) - ) - }) it('should not overwrite a sha256 property when it already exists', async () => { // Create a revision with an existing sha256 property in the document const revisions: HealthPlanRevisionTable[] = createRevisions([ diff --git a/services/app-api/src/handlers/migrate_rate_documents.test.ts b/services/app-api/src/handlers/migrate_rate_documents.test.ts index f1a56560f3..a70b338be9 100644 --- a/services/app-api/src/handlers/migrate_rate_documents.test.ts +++ b/services/app-api/src/handlers/migrate_rate_documents.test.ts @@ -48,6 +48,7 @@ describe('migrate_rate_documents', () => { { s3URL: 's3://bucketname/key/foo.png', name: 'rates cert 1', + sha256: 'fakesha', documentCategories: [ 'RATES_RELATED', ] as DocumentCategoryType[], @@ -55,6 +56,7 @@ describe('migrate_rate_documents', () => { { s3URL: 's3://bucketname/key/foo.png', name: 'rates cert 2', + sha256: 'fakesha', documentCategories: [ 'RATES_RELATED', ] as DocumentCategoryType[], @@ -98,6 +100,7 @@ describe('migrate_rate_documents', () => { { s3URL: 's3://bucketname/key/foo1.png', name: 'rates cert 1', + sha256: 'fakesha', documentCategories: [ 'RATES_RELATED', ] as DocumentCategoryType[], @@ -105,6 +108,7 @@ describe('migrate_rate_documents', () => { { s3URL: 's3://bucketname/key/foo2.png', name: 'rates cert 2', + sha256: 'fakesha', documentCategories: [ 'RATES_RELATED', ] as DocumentCategoryType[], @@ -211,6 +215,7 @@ describe('migrate_rate_documents', () => { { s3URL: 's3://bucketname/key/foo.png', name: 'contract doc', + sha256: 'fakesha', documentCategories: ['RATES_RELATED'], }, ]) @@ -259,6 +264,7 @@ describe('migrate_rate_documents', () => { { s3URL: 's3://bucketname/key/foo.png', name: 'contract doc', + sha256: 'fakesha', documentCategories: ['RATES_RELATED'], }, ]) @@ -346,16 +352,19 @@ describe('migrate_rate_documents', () => { { s3URL: 's3://bucketname/key/foo.png', name: 'Report12 - SFY 2022 Preliminary MississippiCAN Capitation Rates - Exhibits.xlsx', + sha256: 'fakesha', documentCategories: ['RATES_RELATED'], }, { s3URL: 's3://bucketname/key/bar.png', name: 'Report13 - SFY 2023 Preliminary MississippiCAN Capitation Rates - Exhibits.xlsx', + sha256: 'fakesha', documentCategories: ['RATES_RELATED'], }, { s3URL: 's3://bucketname/key/baz.png', name: 'unrelated doc', + sha256: 'fakesha', documentCategories: ['CONTRACT'], }, ], From 9a6f3ebc857534984f0a4c5b9a713ebc3b0e8480 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Mon, 25 Sep 2023 09:55:33 -0700 Subject: [PATCH 12/23] dont force wip test to fail --- .../resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts index d6b73886b1..933520f5db 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts @@ -355,7 +355,7 @@ describe.each(flagValueTestParameters)( ) expect(finalRateDocs).toEqual(['fake doc', 'fake doc number two']) - throw new Error('Not done with this test yet') + // throw new Error('Not done with this test yet') }, 20000) it('can be unlocked repeatedly', async () => { From c12f3384af8bf700bcc430314550d70132854f2d Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Mon, 25 Sep 2023 12:10:15 -0700 Subject: [PATCH 13/23] test everywhere --- .../unlockHealthPlanPackage.test.ts | 16 +++++++++------- .../updateHealthPlanFormData.test.ts | 10 +++++----- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts index 933520f5db..8137d590d6 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts @@ -43,11 +43,11 @@ const flagValueTestParameters: { flagValue: false, testName: 'unlockHealthPlanPackage with all feature flags off', }, - // { - // flagName: 'rates-db-refactor', - // flagValue: true, - // testName: 'unlockHealthPlanPackage with rates-db-refactor on', - // }, + { + flagName: 'rates-db-refactor', + flagValue: true, + testName: 'unlockHealthPlanPackage with rates-db-refactor on', + }, ] describe.each(flagValueTestParameters)( @@ -197,8 +197,9 @@ describe.each(flagValueTestParameters)( }, 20000) it('allows for multiple edits, editing the set of revisions correctly', async () => { - const stateServer = await constructTestPostgresServer() - + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) // First, create a new submitted submission const stateDraft = await createAndSubmitTestHealthPlanPackage( stateServer @@ -209,6 +210,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, }) // Unlock diff --git a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts index 62a3fe90c3..10672f3ce9 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts @@ -36,11 +36,11 @@ const flagValueTestParameters: { flagValue: FlagValue testName: string }[] = [ - // { - // flagName: 'rates-db-refactor', - // flagValue: false, - // testName: 'updateHealthPlanFormData with all feature flags off', - // }, + { + flagName: 'rates-db-refactor', + flagValue: false, + testName: 'updateHealthPlanFormData with all feature flags off', + }, { flagName: 'rates-db-refactor', flagValue: true, From 4e0040d35d5a53fbcb2d8e4c5b97cfe0a318744d Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Mon, 25 Sep 2023 13:00:39 -0700 Subject: [PATCH 14/23] No unlock refactor tests for now --- .../healthPlanPackage/unlockHealthPlanPackage.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts index 8137d590d6..b547c768ce 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts @@ -43,11 +43,11 @@ const flagValueTestParameters: { flagValue: false, testName: 'unlockHealthPlanPackage with all feature flags off', }, - { - flagName: 'rates-db-refactor', - flagValue: true, - testName: 'unlockHealthPlanPackage with rates-db-refactor on', - }, + // { + // flagName: 'rates-db-refactor', + // flagValue: true, + // testName: 'unlockHealthPlanPackage with rates-db-refactor on', + // }, ] describe.each(flagValueTestParameters)( From 293e811ac88c084d3168ea7d0c98642810322677 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Mon, 25 Sep 2023 13:47:20 -0700 Subject: [PATCH 15/23] might need this compliation step --- dev_tool/src/local/graphql.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dev_tool/src/local/graphql.ts b/dev_tool/src/local/graphql.ts index f32bf184b4..71d7453460 100644 --- a/dev_tool/src/local/graphql.ts +++ b/dev_tool/src/local/graphql.ts @@ -13,6 +13,12 @@ async function compileGraphQLTypesWatch(runner: LabeledProcessRunner) { export const compileGraphQLTypesWatchOnce = once(compileGraphQLTypesWatch) async function compileGraphQLTypes(runner: LabeledProcessRunner) { + await runner.runCommandAndOutput( + 'gql deps', + ['yarn', 'install', '--prefer-offline'], + '' + ) + return runner.runCommandAndOutput( 'gqlgen', ['npx', 'lerna', 'run', 'gqlgen'], From 80054afeadbb3e39aea8e7aad464cac9efca8c73 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Tue, 26 Sep 2023 10:19:42 -0700 Subject: [PATCH 16/23] undo all dev tool work --- dev_tool/src/local/api.ts | 2 ++ dev_tool/src/local/graphql.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/dev_tool/src/local/api.ts b/dev_tool/src/local/api.ts index 1529e0190f..63dc2a1ae3 100644 --- a/dev_tool/src/local/api.ts +++ b/dev_tool/src/local/api.ts @@ -4,6 +4,8 @@ import { compileGraphQLTypesWatchOnce } from './graphql.js' import { installPrismaDeps } from './postgres.js' export async function installAPIDeps(runner: LabeledProcessRunner) { + await runner.runCommandAndOutput('api deps', ['yarn', 'install'], '') + // prisma requires that prisma generate is run after any yarn install return installPrismaDeps(runner) } diff --git a/dev_tool/src/local/graphql.ts b/dev_tool/src/local/graphql.ts index 71d7453460..4aff10067f 100644 --- a/dev_tool/src/local/graphql.ts +++ b/dev_tool/src/local/graphql.ts @@ -3,6 +3,8 @@ import { once } from '../deps.js' // run the graphql compiler with --watch async function compileGraphQLTypesWatch(runner: LabeledProcessRunner) { + await runner.runCommandAndOutput('gql deps', ['yarn', 'install'], '') + runner.runCommandAndOutput( 'gqlgen', ['npx', 'lerna', 'run', 'gqlgen:watch'], From 8d0995b0959e48943c3f5c0d9f03b758bd1451cd Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Wed, 27 Sep 2023 12:15:52 -0700 Subject: [PATCH 17/23] everything else --- .../migration.sql | 19 ++ services/app-api/prisma/schema.prisma | 6 + .../{DivisionType.d.ts => DivisionType.ts} | 0 ...kageType.d.ts => HealthPlanPackageType.ts} | 0 .../{ProgramType.d.ts => ProgramType.ts} | 0 .../{StateType.d.ts => StateType.ts} | 0 .../{UserType.d.ts => UserType.ts} | 0 .../convertContractWithRatesToHPP.ts | 4 +- ...llContractsWithHistoryBySubmitInfo.test.ts | 11 +- .../findContractWithHistory.test.ts | 105 +++--- .../findRateWithHistory.test.ts | 82 ++--- .../prismaDraftContractHelpers.ts | 26 +- .../prismaSharedContractRateHelpers.ts | 50 ++- .../contractAndRates/unlockContract.test.ts | 49 +-- .../contractAndRates/unlockContract.ts | 105 +++++- .../postgres/contractAndRates/unlockRate.ts | 115 ++++++- .../updateDraftContractWithRates.ts | 93 +++++- .../app-api/src/postgres/postgresStore.ts | 12 + .../unlockHealthPlanPackage.test.ts | 155 +++++++-- .../unlockHealthPlanPackage.ts | 315 +++++++++++++----- .../updateHealthPlanFormData.test.ts | 2 +- .../contractAndRates/contractHelpers.ts | 5 +- .../contractAndRates/rateHelpers.ts | 3 + .../app-api/src/testHelpers/storeHelpers.ts | 10 + ...ataType.d.ts => HealthPlanFormDataType.ts} | 0 ...e.d.ts => LockedHealthPlanFormDataType.ts} | 5 +- ...d.ts => UnlockedHealthPlanFormDataType.ts} | 9 +- .../healthPlanFormData.test.ts | 47 ++- .../healthPlanFormData.ts | 9 +- .../proto/healthPlanFormDataProto/toDomain.ts | 2 +- .../ContractDetailsSummarySection.test.tsx | 18 +- .../ContractDetailsSummarySection.tsx | 6 +- .../RateDetailsSummarySection.test.tsx | 22 +- ...SupportingDocumentsSummarySection.test.tsx | 5 + .../UploadedDocumentsTable.test.tsx | 21 ++ .../makeDocumentDateLookupTable.test.ts | 3 + .../app-web/src/formHelpers/formatters.ts | 8 +- .../QuestionResponse/QuestionResponse.tsx | 13 +- .../ContractDetails/ContractDetails.test.tsx | 1 + .../ContractDetails/ContractDetails.tsx | 8 +- .../Documents/Documents.test.tsx | 14 +- .../StateSubmission/Documents/Documents.tsx | 8 +- .../StateSubmissionForm.test.tsx | 2 + .../apolloMocks/healthPlanFormDataMock.ts | 31 +- .../apolloMocks/healthPlanPackageGQLMock.ts | 15 + 45 files changed, 1089 insertions(+), 325 deletions(-) create mode 100644 services/app-api/prisma/migrations/20230925230628_add_position_for_sub_tables/migration.sql rename services/app-api/src/domain-models/{DivisionType.d.ts => DivisionType.ts} (100%) rename services/app-api/src/domain-models/{HealthPlanPackageType.d.ts => HealthPlanPackageType.ts} (100%) rename services/app-api/src/domain-models/{ProgramType.d.ts => ProgramType.ts} (100%) rename services/app-api/src/domain-models/{StateType.d.ts => StateType.ts} (100%) rename services/app-api/src/domain-models/{UserType.d.ts => UserType.ts} (100%) rename services/app-web/src/common-code/healthPlanFormDataType/{HealthPlanFormDataType.d.ts => HealthPlanFormDataType.ts} (100%) rename services/app-web/src/common-code/healthPlanFormDataType/{LockedHealthPlanFormDataType.d.ts => LockedHealthPlanFormDataType.ts} (94%) rename services/app-web/src/common-code/healthPlanFormDataType/{UnlockedHealthPlanFormDataType.d.ts => UnlockedHealthPlanFormDataType.ts} (91%) diff --git a/services/app-api/prisma/migrations/20230925230628_add_position_for_sub_tables/migration.sql b/services/app-api/prisma/migrations/20230925230628_add_position_for_sub_tables/migration.sql new file mode 100644 index 0000000000..82b75086cc --- /dev/null +++ b/services/app-api/prisma/migrations/20230925230628_add_position_for_sub_tables/migration.sql @@ -0,0 +1,19 @@ +BEGIN; +-- AlterTable +ALTER TABLE "ActuaryContact" ADD COLUMN "position" INTEGER NOT NULL DEFAULT -1; + +-- AlterTable +ALTER TABLE "ContractDocument" ADD COLUMN "position" INTEGER NOT NULL DEFAULT -1; + +-- AlterTable +ALTER TABLE "ContractSupportingDocument" ADD COLUMN "position" INTEGER NOT NULL DEFAULT -1; + +-- AlterTable +ALTER TABLE "RateDocument" ADD COLUMN "position" INTEGER NOT NULL DEFAULT -1; + +-- AlterTable +ALTER TABLE "RateSupportingDocument" ADD COLUMN "position" INTEGER NOT NULL DEFAULT -1; + +-- AlterTable +ALTER TABLE "StateContact" ADD COLUMN "position" INTEGER NOT NULL DEFAULT -1; +COMMIT; diff --git a/services/app-api/prisma/schema.prisma b/services/app-api/prisma/schema.prisma index 691975f5f4..fc1d4a71a7 100644 --- a/services/app-api/prisma/schema.prisma +++ b/services/app-api/prisma/schema.prisma @@ -178,6 +178,7 @@ model ActuaryContact { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + position Int @default(-1) name String? titleRole String? email String? @@ -193,6 +194,7 @@ model ContractDocument { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + position Int @default(-1) name String s3URL String sha256 String @@ -204,6 +206,7 @@ model ContractSupportingDocument { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + position Int @default(-1) name String s3URL String sha256 String @@ -215,6 +218,7 @@ model RateDocument { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + position Int @default(-1) name String s3URL String sha256 String @@ -226,6 +230,7 @@ model RateSupportingDocument { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + position Int @default(-1) name String s3URL String sha256 String @@ -237,6 +242,7 @@ model StateContact { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + position Int @default(-1) name String? titleRole String? email String? diff --git a/services/app-api/src/domain-models/DivisionType.d.ts b/services/app-api/src/domain-models/DivisionType.ts similarity index 100% rename from services/app-api/src/domain-models/DivisionType.d.ts rename to services/app-api/src/domain-models/DivisionType.ts diff --git a/services/app-api/src/domain-models/HealthPlanPackageType.d.ts b/services/app-api/src/domain-models/HealthPlanPackageType.ts similarity index 100% rename from services/app-api/src/domain-models/HealthPlanPackageType.d.ts rename to services/app-api/src/domain-models/HealthPlanPackageType.ts diff --git a/services/app-api/src/domain-models/ProgramType.d.ts b/services/app-api/src/domain-models/ProgramType.ts similarity index 100% rename from services/app-api/src/domain-models/ProgramType.d.ts rename to services/app-api/src/domain-models/ProgramType.ts diff --git a/services/app-api/src/domain-models/StateType.d.ts b/services/app-api/src/domain-models/StateType.ts similarity index 100% rename from services/app-api/src/domain-models/StateType.d.ts rename to services/app-api/src/domain-models/StateType.ts diff --git a/services/app-api/src/domain-models/UserType.d.ts b/services/app-api/src/domain-models/UserType.ts similarity index 100% rename from services/app-api/src/domain-models/UserType.d.ts rename to services/app-api/src/domain-models/UserType.ts diff --git a/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts b/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts index 1b031e8a98..6f82ce5b41 100644 --- a/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts +++ b/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts @@ -4,7 +4,7 @@ import type { HealthPlanFormDataType, RateInfoType, SubmissionDocument, -} from 'app-web/src/common-code/healthPlanFormDataType' +} from '../../../../app-web/src/common-code/healthPlanFormDataType' import type { HealthPlanPackageType, HealthPlanRevisionType, @@ -13,7 +13,7 @@ import type { ContractType } from './contractTypes' import { toDomain, toProtoBuffer, -} from 'app-web/src/common-code/proto/healthPlanFormDataProto' +} from '../../../../app-web/src/common-code/proto/healthPlanFormDataProto' import type { ContractRevisionWithRatesType } from './revisionTypes' import { isSubmissionError, diff --git a/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.test.ts b/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.test.ts index 420f12bee5..82662f9f52 100644 --- a/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.test.ts @@ -76,12 +76,11 @@ describe('findAllContractsWithHistoryBySubmittedInfo', () => { }) ) const unlockedContract = must( - await unlockContract( - client, - contractThree.id, - cmsUser.id, - 'unlock unlockContractOne' - ) + await unlockContract(client, { + contractID: contractThree.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock unlockContractOne', + }) ) // call the find by submit info function diff --git a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts index 08399238bc..1af3893792 100644 --- a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts @@ -128,12 +128,11 @@ describe('findContract', () => { // remove the connection from rate 2 must( - await unlockRate( - client, - rate2.id, - cmsUser.id, - 'unlock for 2.1 remove' - ) + await unlockRate(client, { + rateID: rate2.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 2.1 remove', + }) ) must( await updateDraftRate(client, { @@ -161,7 +160,13 @@ describe('findContract', () => { expect(twoContract.revisions[0].rateRevisions).toHaveLength(2) // update rate 1 to have a new version, should make one new rev. - must(await unlockRate(client, rate1.id, cmsUser.id, 'unlock for 1.1')) + must( + await unlockRate(client, { + rateID: rate1.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 1.1', + }) + ) must( await updateDraftRate(client, { rateID: rate1.id, @@ -188,12 +193,11 @@ describe('findContract', () => { // Make a new Contract Revision, should show up as a single new rev with all the old info must( - await unlockContract( - client, - contractA.id, - cmsUser.id, - 'unlocking A.0' - ) + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking A.0', + }) ) must( await submitContract(client, { @@ -214,12 +218,11 @@ describe('findContract', () => { // Make a new Contract Revision, changing the connections should show up as a single new rev. const unlockedContractA = must( - await unlockContract( - client, - contractA.id, - cmsUser.id, - 'unlocking A.1' - ) + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking A.1', + }) ) must( await updateDraftContractWithRates(client, { @@ -460,12 +463,11 @@ describe('findContract', () => { // remove the connection from rate 2 must( - await unlockRate( - client, - rate2.id, - cmsUser.id, - 'unlock for 2.1 remove' - ) + await unlockRate(client, { + rateID: rate2.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 2.1 remove', + }) ) must( await updateDraftRate(client, { @@ -483,7 +485,13 @@ describe('findContract', () => { ) // update rate 1 to have a new version, should make one new rev. - must(await unlockRate(client, rate1.id, cmsUser.id, 'unlock for 1.1')) + must( + await unlockRate(client, { + rateID: rate1.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 1.1', + }) + ) must( await updateDraftRate(client, { rateID: rate1.id, @@ -501,13 +509,13 @@ describe('findContract', () => { // Make a new Contract Revision, should show up as a single new rev with all the old info must( - await unlockContract( - client, - contractA.id, - cmsUser.id, - 'unlocking A.0' - ) + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking A.0', + }) ) + must( await submitContract(client, { contractID: contractA.id, @@ -518,12 +526,11 @@ describe('findContract', () => { // Make a new Contract Revision, changing the connections should show up as a single new rev. const unlockedContractA = must( - await unlockContract( - client, - contractA.id, - cmsUser.id, - 'unlocking A.1' - ) + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking A.1', + }) ) // Remove rate 1 and rate 2 from contract must( @@ -718,12 +725,11 @@ describe('findContract', () => { // Unlock contract A, but don't resubmit it yet. must( - await unlockContract( - client, - contractA.id, - cmsUser.id, - 'unlock A Open' - ) + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock A Open', + }) ) // Draft should pull revision 2.0 out @@ -739,12 +745,11 @@ describe('findContract', () => { // unlock and submit second rate rev must( - await unlockRate( - client, - submittedRate2.id, - cmsUser.id, - 'unlock for 2.1' - ) + await unlockRate(client, { + rateID: submittedRate2.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 2.1', + }) ) must( await updateDraftRate(client, { diff --git a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts index 408ca1ab80..ebe236710e 100644 --- a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts @@ -144,12 +144,11 @@ describe('findRate', () => { // remove the connection from contract 2 const unlockedContract2 = must( - await unlockContract( - client, - contract2.id, - cmsUser.id, - 'unlock for 2.1 remove' - ) + await unlockContract(client, { + contractID: contract2.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 2.1 remove', + }) ) must( await updateDraftContractWithRates(client, { @@ -187,12 +186,11 @@ describe('findRate', () => { // update rate 1 to have a new version, should make one new rev. const unlockedContract1 = must( - await unlockContract( - client, - contract1.id, - cmsUser.id, - 'unlock for 1.1' - ) + await unlockContract(client, { + contractID: contract1.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 1.1', + }) ) as DraftContractType must( await updateDraftContractWithRates(client, { @@ -219,7 +217,13 @@ describe('findRate', () => { expect(backAgainRate.revisions).toHaveLength(6) // Make a new Contract Revision, should show up as a single new rev with all the old info - must(await unlockRate(client, rateA.id, cmsUser.id, 'unlocking A.0')) + must( + await unlockRate(client, { + rateID: rateA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking A.0', + }) + ) const resubmittedRateA = must( await submitRate(client, { rateID: rateA.id, @@ -239,12 +243,11 @@ describe('findRate', () => { // Make a new Rate Revision, changing the connections should show up as a single new rev. const secondUnlockRateA = must( - await unlockRate( - client, - resubmittedRateA.id, - cmsUser.id, - 'unlocking A.1' - ) + await unlockRate(client, { + rateID: resubmittedRateA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking A.1', + }) ) must( await updateDraftRate(client, { @@ -476,12 +479,11 @@ describe('findRate', () => { // remove the connection from rate 2 must( - await unlockRate( - client, - rate2.id, - cmsUser.id, - 'unlock for 2.1 remove' - ) + await unlockRate(client, { + rateID: rate2.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 2.1 remove', + }) ) must( await updateDraftRate(client, { @@ -499,7 +501,13 @@ describe('findRate', () => { ) // update rate 1 to have a new version, should make one new rev. - must(await unlockRate(client, rate1.id, cmsUser.id, 'unlock for 1.1')) + must( + await unlockRate(client, { + rateID: rate1.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlock for 1.1', + }) + ) must( await updateDraftRate(client, { rateID: rate1.id, @@ -517,12 +525,11 @@ describe('findRate', () => { // Make a new Contract Revision, should show up as a single new rev with all the old info must( - await unlockContract( - client, - contractA.id, - cmsUser.id, - 'unlocking A.0' - ) + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking A.0', + }) ) must( @@ -548,12 +555,11 @@ describe('findRate', () => { // Make a new Contract Revision, changing the connections should show up as a single new rev. must( - await unlockContract( - client, - contractA.id, - cmsUser.id, - 'unlocking A.1' - ) + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking A.1', + }) ) const updatedDraftContractA = must( await updateDraftContractWithRates(client, { diff --git a/services/app-api/src/postgres/contractAndRates/prismaDraftContractHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaDraftContractHelpers.ts index 21398e0cf7..a7384d986a 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaDraftContractHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaDraftContractHelpers.ts @@ -5,6 +5,7 @@ import type { } from '../../domain-models/contractAndRates' import { contractFormDataToDomainModel, + convertUpdateInfoToDomainModel, includeUpdateInfo, rateRevisionToDomainModel, } from './prismaSharedContractRateHelpers' @@ -13,10 +14,26 @@ import type { ContractRevisionTableWithRates } from './prismaSubmittedContractHe const includeDraftRates = { revisions: { include: { - rateDocuments: true, - supportingDocuments: true, - certifyingActuaryContacts: true, - addtlActuaryContacts: true, + rateDocuments: { + orderBy: { + position: 'asc', + }, + }, + supportingDocuments: { + orderBy: { + position: 'asc', + }, + }, + certifyingActuaryContacts: { + orderBy: { + position: 'asc', + }, + }, + addtlActuaryContacts: { + orderBy: { + position: 'asc', + }, + }, submitInfo: includeUpdateInfo, unlockInfo: includeUpdateInfo, }, @@ -46,6 +63,7 @@ function draftContractRevToDomainModel( id: revision.id, createdAt: revision.createdAt, updatedAt: revision.updatedAt, + unlockInfo: convertUpdateInfoToDomainModel(revision.unlockInfo), formData: contractFormDataToDomainModel(revision), rateRevisions: draftRatesToDomainModel(revision.draftRates), } diff --git a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts index 2c38d45581..5049c9a521 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts @@ -65,10 +65,26 @@ const includeRateFormData = { submitInfo: includeUpdateInfo, unlockInfo: includeUpdateInfo, - rateDocuments: true, - supportingDocuments: true, - certifyingActuaryContacts: true, - addtlActuaryContacts: true, + rateDocuments: { + orderBy: { + position: 'asc', + }, + }, + supportingDocuments: { + orderBy: { + position: 'asc', + }, + }, + certifyingActuaryContacts: { + orderBy: { + position: 'asc', + }, + }, + addtlActuaryContacts: { + orderBy: { + position: 'asc', + }, + }, } satisfies Prisma.RateRevisionTableInclude type RateRevisionTableWithFormData = Prisma.RateRevisionTableGetPayload<{ @@ -151,7 +167,13 @@ function rateRevisionToDomainModel( function ratesRevisionsToDomainModel( rateRevisions: RateRevisionTableWithFormData[] ): RateRevisionType[] { - return rateRevisions.map((rrev) => rateRevisionToDomainModel(rrev)) + const domainRevisions = rateRevisions.map((rrev) => + rateRevisionToDomainModel(rrev) + ) + domainRevisions.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime() + ) + return domainRevisions } // ------ @@ -160,9 +182,21 @@ const includeContractFormData = { unlockInfo: includeUpdateInfo, submitInfo: includeUpdateInfo, - stateContacts: true, - contractDocuments: true, - supportingDocuments: true, + stateContacts: { + orderBy: { + position: 'asc', + }, + }, + contractDocuments: { + orderBy: { + position: 'asc', + }, + }, + supportingDocuments: { + orderBy: { + position: 'asc', + }, + }, } satisfies Prisma.ContractRevisionTableInclude type ContractRevisionTableWithFormData = diff --git a/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts b/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts index d6160ba857..1cc48d4d95 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts @@ -93,7 +93,13 @@ describe('unlockContract', () => { ) // Unlock the rate - must(await unlockRate(client, rate.id, cmsUser.id, 'Unlocking rate')) + must( + await unlockRate(client, { + rateID: rate.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'Unlocking rate', + }) + ) must( await updateDraftRate(client, { rateID: rate.id, @@ -207,7 +213,13 @@ describe('unlockContract', () => { ) // Unlock the rate and resubmit rate - must(await unlockRate(client, rate.id, cmsUser.id, 'Unlocking rate')) + must( + await unlockRate(client, { + rateID: rate.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'Unlocking rate', + }) + ) must( await updateDraftRate(client, { rateID: rate.id, @@ -322,12 +334,11 @@ describe('unlockContract', () => { // Unlock and resubmit contract must( - await unlockContract( - client, - contract.id, - cmsUser.id, - 'First unlock' - ) + await unlockContract(client, { + contractID: contract.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'First unlock', + }) ) must( await updateDraftContractWithRates(client, { @@ -447,20 +458,18 @@ describe('unlockContract', () => { //Unlocking it results in error expect( - await unlockContract( - client, - contractA.id, - cmsUser.id, - 'unlocking contact A 1.1' - ) + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking contact A 1.1', + }) ).toBeInstanceOf(Error) expect( - await unlockRate( - client, - rateA.id, - cmsUser.id, - 'unlocking rate A 1.1' - ) + await unlockRate(client, { + rateID: rateA.id, + unlockedByUserID: cmsUser.id, + unlockReason: 'unlocking rate A 1.1', + }) ).toBeInstanceOf(Error) }) }) diff --git a/services/app-api/src/postgres/contractAndRates/unlockContract.ts b/services/app-api/src/postgres/contractAndRates/unlockContract.ts index f62eac2d60..2628919594 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockContract.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockContract.ts @@ -3,17 +3,23 @@ import type { ContractType } from '../../domain-models/contractAndRates' import { findContractWithHistory } from './findContractWithHistory' import { NotFoundError } from '../storeError' +type UnlockContractArgsType = { + contractID: string + unlockedByUserID: string + unlockReason: string +} + // Unlock the given contract // * copy form data // * set relationships based on last submission async function unlockContract( client: PrismaClient, - contractID: string, - unlockedByUserID: string, - unlockReason: string + args: UnlockContractArgsType ): Promise { const groupTime = new Date() + const { contractID, unlockedByUserID, unlockReason } = args + try { return await client.$transaction(async (tx) => { // Given all the Rates associated with this draft, find the most recent submitted @@ -26,6 +32,22 @@ async function unlockContract( createdAt: 'desc', }, include: { + contractDocuments: { + orderBy: { + position: 'asc', + }, + }, + supportingDocuments: { + orderBy: { + position: 'asc', + }, + }, + stateContacts: { + orderBy: { + position: 'asc', + }, + }, + rateRevisions: { where: { validUntil: null, @@ -62,12 +84,6 @@ async function unlockContract( id: contractID, }, }, - submissionType: currentRev.submissionType, - submissionDescription: currentRev.submissionDescription, - contractType: currentRev.contractType, - populationCovered: currentRev.populationCovered, - programIDs: currentRev.programIDs, - riskBasedContract: currentRev.riskBasedContract, unlockInfo: { create: { updatedAt: groupTime, @@ -80,6 +96,76 @@ async function unlockContract( id: cID, })), }, + + populationCovered: currentRev.populationCovered, + programIDs: currentRev.programIDs, + riskBasedContract: currentRev.riskBasedContract, + submissionType: currentRev.submissionType, + submissionDescription: currentRev.submissionDescription, + contractType: currentRev.contractType, + contractExecutionStatus: currentRev.contractExecutionStatus, + contractDateStart: currentRev.contractDateStart, + contractDateEnd: currentRev.contractDateEnd, + managedCareEntities: currentRev.managedCareEntities, + federalAuthorities: currentRev.federalAuthorities, + inLieuServicesAndSettings: + currentRev.inLieuServicesAndSettings, + modifiedBenefitsProvided: + currentRev.modifiedBenefitsProvided, + modifiedGeoAreaServed: currentRev.modifiedGeoAreaServed, + modifiedMedicaidBeneficiaries: + currentRev.modifiedMedicaidBeneficiaries, + modifiedRiskSharingStrategy: + currentRev.modifiedRiskSharingStrategy, + modifiedIncentiveArrangements: + currentRev.modifiedIncentiveArrangements, + modifiedWitholdAgreements: + currentRev.modifiedWitholdAgreements, + modifiedStateDirectedPayments: + currentRev.modifiedStateDirectedPayments, + modifiedPassThroughPayments: + currentRev.modifiedPassThroughPayments, + modifiedPaymentsForMentalDiseaseInstitutions: + currentRev.modifiedPaymentsForMentalDiseaseInstitutions, + modifiedMedicalLossRatioStandards: + currentRev.modifiedMedicalLossRatioStandards, + modifiedOtherFinancialPaymentIncentive: + currentRev.modifiedOtherFinancialPaymentIncentive, + modifiedEnrollmentProcess: + currentRev.modifiedEnrollmentProcess, + modifiedGrevienceAndAppeal: + currentRev.modifiedGrevienceAndAppeal, + modifiedNetworkAdequacyStandards: + currentRev.modifiedNetworkAdequacyStandards, + modifiedLengthOfContract: + currentRev.modifiedLengthOfContract, + modifiedNonRiskPaymentArrangements: + currentRev.modifiedNonRiskPaymentArrangements, + + contractDocuments: { + create: currentRev.contractDocuments.map((d) => ({ + position: d.position, + name: d.name, + s3URL: d.s3URL, + sha256: d.sha256, + })), + }, + supportingDocuments: { + create: currentRev.supportingDocuments.map((d) => ({ + position: d.position, + name: d.name, + s3URL: d.s3URL, + sha256: d.sha256, + })), + }, + stateContacts: { + create: currentRev.stateContacts.map((c) => ({ + position: c.position, + name: c.name, + email: c.email, + titleRole: c.titleRole, + })), + }, }, include: { rateRevisions: { @@ -99,3 +185,4 @@ async function unlockContract( } export { unlockContract } +export type { UnlockContractArgsType } diff --git a/services/app-api/src/postgres/contractAndRates/unlockRate.ts b/services/app-api/src/postgres/contractAndRates/unlockRate.ts index cf9930c8f9..9eefdf8f66 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockRate.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockRate.ts @@ -2,29 +2,67 @@ import type { PrismaClient } from '@prisma/client' import type { RateType } from '../../domain-models/contractAndRates' import { findRateWithHistory } from './findRateWithHistory' +type UnlockRateArgsType = { + rateID?: string + rateRevisionID?: string + unlockedByUserID: string + unlockReason: string +} + // Unlock the given rate // * copy form data // * set relationships based on last submission async function unlockRate( client: PrismaClient, - rateID: string, - unlockedByUserID: string, - unlockReason: string + args: UnlockRateArgsType ): Promise { const groupTime = new Date() + const { rateID, rateRevisionID, unlockedByUserID, unlockReason } = args + + // this is a hack that should not outlive protobuf. Protobufs only have + // rate revision IDs in them, so we allow submitting by rate revisionID from our submitHPP resolver + if (!rateID && !rateRevisionID) { + return new Error( + 'Either rateID or rateRevisionID must be supplied. both are blank' + ) + } try { return await client.$transaction(async (tx) => { + const findWhere = rateRevisionID + ? { + id: rateRevisionID, + } + : { + rateID, + } + // Given all the Rates associated with this draft, find the most recent submitted // rateRevision to attach to this contract on submit. const currentRev = await tx.rateRevisionTable.findFirst({ - where: { - rateID: rateID, - }, - orderBy: { - createdAt: 'desc', - }, + where: findWhere, include: { + rateDocuments: { + orderBy: { + position: 'asc', + }, + }, + supportingDocuments: { + orderBy: { + position: 'asc', + }, + }, + certifyingActuaryContacts: { + orderBy: { + position: 'asc', + }, + }, + addtlActuaryContacts: { + orderBy: { + position: 'asc', + }, + }, + contractRevisions: { where: { validUntil: null, @@ -62,10 +100,9 @@ async function unlockRate( data: { rate: { connect: { - id: rateID, + id: currentRev.rateID, }, }, - rateCertificationName: currentRev.rateCertificationName, unlockInfo: { create: { updatedAt: groupTime, @@ -78,6 +115,59 @@ async function unlockRate( id: cID, })), }, + + rateType: currentRev.rateType, + rateCapitationType: currentRev.rateCapitationType, + rateDateStart: currentRev.rateDateStart, + rateDateEnd: currentRev.rateDateEnd, + rateDateCertified: currentRev.rateDateCertified, + amendmentEffectiveDateEnd: + currentRev.amendmentEffectiveDateEnd, + amendmentEffectiveDateStart: + currentRev.amendmentEffectiveDateStart, + rateProgramIDs: currentRev.rateProgramIDs, + rateCertificationName: currentRev.rateCertificationName, + actuaryCommunicationPreference: + currentRev.actuaryCommunicationPreference, + + rateDocuments: { + create: currentRev.rateDocuments.map((d) => ({ + position: d.position, + name: d.name, + s3URL: d.s3URL, + sha256: d.sha256, + })), + }, + supportingDocuments: { + create: currentRev.supportingDocuments.map((d) => ({ + position: d.position, + name: d.name, + s3URL: d.s3URL, + sha256: d.sha256, + })), + }, + certifyingActuaryContacts: { + create: currentRev.certifyingActuaryContacts.map( + (c) => ({ + position: c.position, + name: c.name, + email: c.email, + titleRole: c.titleRole, + actuarialFirm: c.actuarialFirm, + actuarialFirmOther: c.actuarialFirmOther, + }) + ), + }, + addtlActuaryContacts: { + create: currentRev.addtlActuaryContacts.map((c) => ({ + position: c.position, + name: c.name, + email: c.email, + titleRole: c.titleRole, + actuarialFirm: c.actuarialFirm, + actuarialFirmOther: c.actuarialFirmOther, + })), + }, }, include: { contractRevisions: { @@ -88,7 +178,7 @@ async function unlockRate( }, }) - return findRateWithHistory(tx, rateID) + return findRateWithHistory(tx, currentRev.rateID) }) } catch (err) { console.error('SUBMIT PRISMA CONTRACT ERR', err) @@ -97,3 +187,4 @@ async function unlockRate( } export { unlockRate } +export type { UnlockRateArgsType } diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts index 00aa11f89d..e19474d771 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts @@ -242,16 +242,44 @@ async function updateDraftContractWithRates( rateCertificationName: rateRevision.rateCertificationName, rateDocuments: { - create: rateRevision.rateDocuments, + create: + rateRevision.rateDocuments && + rateRevision.rateDocuments.map( + (d, idx) => ({ + position: idx, + ...d, + }) + ), }, supportingDocuments: { - create: rateRevision.supportingDocuments, + create: + rateRevision.supportingDocuments && + rateRevision.supportingDocuments.map( + (d, idx) => ({ + position: idx, + ...d, + }) + ), }, certifyingActuaryContacts: { - create: rateRevision.certifyingActuaryContacts, + create: + rateRevision.certifyingActuaryContacts && + rateRevision.certifyingActuaryContacts.map( + (c, idx) => ({ + position: idx, + ...c, + }) + ), }, addtlActuaryContacts: { - create: rateRevision.addtlActuaryContacts, + create: + rateRevision.addtlActuaryContacts && + rateRevision.addtlActuaryContacts.map( + (c, idx) => ({ + position: idx, + ...c, + }) + ), }, actuaryCommunicationPreference: rateRevision.actuaryCommunicationPreference, @@ -315,19 +343,47 @@ async function updateDraftContractWithRates( ), rateDocuments: { deleteMany: {}, - create: rateRevision.rateDocuments, + create: + rateRevision.rateDocuments && + rateRevision.rateDocuments.map( + (d, idx) => ({ + position: idx, + ...d, + }) + ), }, supportingDocuments: { deleteMany: {}, - create: rateRevision.supportingDocuments, + create: + rateRevision.supportingDocuments && + rateRevision.supportingDocuments.map( + (d, idx) => ({ + position: idx, + ...d, + }) + ), }, certifyingActuaryContacts: { deleteMany: {}, - create: rateRevision.certifyingActuaryContacts, + create: + rateRevision.certifyingActuaryContacts && + rateRevision.certifyingActuaryContacts.map( + (c, idx) => ({ + position: idx, + ...c, + }) + ), }, addtlActuaryContacts: { deleteMany: {}, - create: rateRevision.addtlActuaryContacts, + create: + rateRevision.addtlActuaryContacts && + rateRevision.addtlActuaryContacts.map( + (c, idx) => ({ + position: idx, + ...c, + }) + ), }, actuaryCommunicationPreference: nullify( @@ -363,15 +419,30 @@ async function updateDraftContractWithRates( contractExecutionStatus: nullify(contractExecutionStatus), contractDocuments: { deleteMany: {}, - create: contractDocuments, + create: + contractDocuments && + contractDocuments.map((d, idx) => ({ + position: idx, + ...d, + })), }, supportingDocuments: { deleteMany: {}, - create: supportingDocuments, + create: + supportingDocuments && + supportingDocuments.map((d, idx) => ({ + position: idx, + ...d, + })), }, stateContacts: { deleteMany: {}, - create: stateContacts, + create: + stateContacts && + stateContacts.map((c, idx) => ({ + position: idx, + ...c, + })), }, contractDateStart: nullify(contractDateStart), contractDateEnd: nullify(contractDateEnd), diff --git a/services/app-api/src/postgres/postgresStore.ts b/services/app-api/src/postgres/postgresStore.ts index b51630ab4c..eadf7acc9a 100644 --- a/services/app-api/src/postgres/postgresStore.ts +++ b/services/app-api/src/postgres/postgresStore.ts @@ -65,6 +65,10 @@ import type { UpdateContractArgsType, } from './contractAndRates' import type { ContractOrErrorArrayType } from './contractAndRates/findAllContractsWithHistoryByState' +import { unlockContract } from './contractAndRates/unlockContract' +import type { UnlockContractArgsType } from './contractAndRates/unlockContract' +import { unlockRate } from './contractAndRates/unlockRate' +import type { UnlockRateArgsType } from './contractAndRates/unlockRate' type Store = { findPrograms: ( @@ -167,6 +171,12 @@ type Store = { ) => Promise submitRate: (args: SubmitRateArgsType) => Promise + + unlockContract: ( + args: UnlockContractArgsType + ) => Promise + + unlockRate: (args: UnlockRateArgsType) => Promise } function NewPostgresStore(client: PrismaClient): Store { @@ -235,6 +245,8 @@ function NewPostgresStore(client: PrismaClient): Store { findAllContractsWithHistoryBySubmitInfo(client), submitContract: (args) => submitContract(client, args), submitRate: (args) => submitRate(client, args), + unlockContract: (args) => unlockContract(client, args), + unlockRate: (args) => unlockRate(client, args), } } diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts index b547c768ce..2a0c8b1eb2 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts @@ -1,6 +1,9 @@ import type { GraphQLError } from 'graphql' import UNLOCK_HEALTH_PLAN_PACKAGE from '../../../../app-graphql/src/mutations/unlockHealthPlanPackage.graphql' -import type { HealthPlanPackage } from '../../gen/gqlServer' +import type { + HealthPlanPackage, + HealthPlanRevisionEdge, +} from '../../gen/gqlServer' import { todaysDate } from '../../testHelpers/dateHelpers' import { constructTestPostgresServer, @@ -22,6 +25,7 @@ import { generateRateName, packageName, } from 'app-web/src/common-code/healthPlanFormDataType' +import type { HealthPlanFormDataType } from 'app-web/src/common-code/healthPlanFormDataType' import { getTestStateAnalystsEmails, mockEmailParameterStoreError, @@ -43,11 +47,11 @@ const flagValueTestParameters: { flagValue: false, testName: 'unlockHealthPlanPackage with all feature flags off', }, - // { - // flagName: 'rates-db-refactor', - // flagValue: true, - // testName: 'unlockHealthPlanPackage with rates-db-refactor on', - // }, + { + flagName: 'rates-db-refactor', + flagValue: true, + testName: 'unlockHealthPlanPackage with rates-db-refactor on', + }, ] describe.each(flagValueTestParameters)( @@ -70,6 +74,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, }) // Unlock @@ -124,7 +129,9 @@ describe.each(flagValueTestParameters)( }, 20000) it('returns a package that can be updated without errors', async () => { - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) // First, create a new submitted submission const stateSubmission = await createAndSubmitTestHealthPlanPackage( @@ -135,6 +142,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, }) // Unlock @@ -200,7 +208,7 @@ describe.each(flagValueTestParameters)( const stateServer = await constructTestPostgresServer({ ldService: mockLDService, }) - // First, create a new submitted submission + // First, create a new submitted submission // SUBMISSION 1 const stateDraft = await createAndSubmitTestHealthPlanPackage( stateServer // unlockedWithFullRates(), @@ -263,11 +271,41 @@ describe.each(flagValueTestParameters)( sha256: 'fakesha', documentCategories: ['RATES'], }, + { + name: 'fake doc 2', + s3URL: 'foo://bar', + sha256: 'fakesha', + documentCategories: ['RATES'], + }, + { + name: 'fake doc 3', + s3URL: 'foo://bar', + sha256: 'fakesha', + documentCategories: ['RATES'], + }, + { + name: 'fake doc 4', + s3URL: 'foo://bar', + sha256: 'fakesha', + documentCategories: ['RATES'], + }, ], supportingDocuments: [], actuaryContacts: [ { - name: 'Enrico Soletzo', + name: 'Enrico Soletzo 1', + titleRole: 'person', + email: 'en@example.com', + actuarialFirm: 'MERCER', + }, + { + name: 'Enrico Soletzo 2', + titleRole: 'person', + email: 'en@example.com', + actuarialFirm: 'MERCER', + }, + { + name: 'Enrico Soletzo 3', titleRole: 'person', email: 'en@example.com', actuarialFirm: 'MERCER', @@ -325,6 +363,7 @@ describe.each(flagValueTestParameters)( ]) await resubmitTestHealthPlanPackage( + // SUBMISSION 2 stateServer, stateDraft.id, 'Test first resubmission reason' @@ -344,6 +383,7 @@ describe.each(flagValueTestParameters)( await updateTestHealthPlanFormData(stateServer, unlockedFormData) const finallySubmittedPKG = await resubmitTestHealthPlanPackage( + // SUBMISSION 3 stateServer, stateDraft.id, 'Test second resubmission reason' @@ -357,11 +397,44 @@ describe.each(flagValueTestParameters)( ) expect(finalRateDocs).toEqual(['fake doc', 'fake doc number two']) + // check document order + const docsInOrder = + finallySubmittedFormData.rateInfos[0].rateDocuments.map( + (d) => d.name + ) + expect(docsInOrder).toEqual([ + 'fake doc', + 'fake doc 2', + 'fake doc 3', + 'fake doc 4', + ]) + + // check contacts order + const actuariesInOrder = + finallySubmittedFormData.rateInfos[0].actuaryContacts.map( + (c) => c.name + ) + expect(actuariesInOrder).toEqual([ + 'Enrico Soletzo 1', + 'Enrico Soletzo 2', + 'Enrico Soletzo 3', + ]) + + // check the history makes sense. + const formDatas: HealthPlanFormDataType[] = + finallySubmittedPKG.revisions.map((r: HealthPlanRevisionEdge) => + base64ToDomain(r.node.formDataProto) + ) + + expect(formDatas).toHaveLength(6) // This probably doesn't make sense totally but is fine for now. + // throw new Error('Not done with this test yet') }, 20000) it('can be unlocked repeatedly', async () => { - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) // First, create a new submitted submission const stateSubmission = await createAndSubmitTestHealthPlanPackage( @@ -372,6 +445,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, }) await unlockTestHealthPlanPackage( @@ -383,7 +457,7 @@ describe.each(flagValueTestParameters)( await resubmitTestHealthPlanPackage( stateServer, stateSubmission.id, - 'Test first resubmission reason' + 'Test second resubmission reason' ) await unlockTestHealthPlanPackage( @@ -424,7 +498,9 @@ describe.each(flagValueTestParameters)( ) // this can be completed after unlock - want to create, submit, unlock, then re-edit it('returns errors if a state user tries to unlock', async () => { - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) // First, create a new submitted submission const stateSubmission = await createAndSubmitTestHealthPlanPackage( @@ -450,11 +526,14 @@ describe.each(flagValueTestParameters)( }) it('returns errors if trying to unlock package with wrong package status', async () => { - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) const cmsServer = await constructTestPostgresServer({ context: { user: cmsUser, }, + ldService: mockLDService, }) // First, create a new draft submission @@ -478,14 +557,9 @@ describe.each(flagValueTestParameters)( expect(err.extensions).toEqual( expect.objectContaining({ - code: 'INTERNAL_SERVER_ERROR', + code: 'BAD_USER_INPUT', cause: 'INVALID_PACKAGE_STATUS', - exception: { - locations: undefined, - message: - 'Attempted to unlock package with wrong status', - path: undefined, - }, + argumentName: 'pkgID', }) ) expect(err.message).toBe( @@ -517,14 +591,9 @@ describe.each(flagValueTestParameters)( expect(unlockErr.extensions).toEqual( expect.objectContaining({ - code: 'INTERNAL_SERVER_ERROR', + code: 'BAD_USER_INPUT', cause: 'INVALID_PACKAGE_STATUS', - exception: { - locations: undefined, - message: - 'Attempted to unlock package with wrong status', - path: undefined, - }, + argumentName: 'pkgID', }) ) expect(unlockErr.message).toBe( @@ -537,6 +606,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, }) // First, create a new submitted submission @@ -570,6 +640,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, }) // Unlock @@ -587,13 +658,15 @@ describe.each(flagValueTestParameters)( const err = (unlockResult.errors as GraphQLError[])[0] expect(err.extensions['code']).toBe('INTERNAL_SERVER_ERROR') - expect(err.message).toBe( - 'Issue finding a package of type UNEXPECTED_EXCEPTION. Message: this error came from the generic store with errors mock' + expect(err.message).toContain( + 'error came from the generic store with errors mock' ) }) it('returns errors if unlocked reason is undefined', async () => { - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) // First, create a new submitted submission const stateSubmission = await createAndSubmitTestHealthPlanPackage( @@ -604,6 +677,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, }) // Attempt Unlock Draft @@ -630,7 +704,9 @@ describe.each(flagValueTestParameters)( const config = testEmailConfig() const mockEmailer = testEmailer(config) //mock invoke email submit lambda - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) // First, create a new submitted submission const stateSubmission = await createAndSubmitTestHealthPlanPackage( @@ -641,6 +717,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, emailer: mockEmailer, }) @@ -691,13 +768,16 @@ describe.each(flagValueTestParameters)( const config = testEmailConfig() const mockEmailer = testEmailer(config) //mock invoke email submit lambda - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) const stateServerTwo = await constructTestPostgresServer({ context: { user: testStateUser({ email: 'notspiderman@example.com', }), }, + ldService: mockLDService, }) // First, create a new submitted submission @@ -709,6 +789,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, emailer: mockEmailer, }) @@ -778,7 +859,9 @@ describe.each(flagValueTestParameters)( const mockEmailer = testEmailer(config) //mock invoke email submit lambda const mockEmailParameterStore = mockEmailParameterStoreError() - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) // First, create a new submitted submission const stateSubmission = await createAndSubmitTestHealthPlanPackage( @@ -789,6 +872,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, emailer: mockEmailer, emailParameterStore: mockEmailParameterStore, }) @@ -816,7 +900,9 @@ describe.each(flagValueTestParameters)( it('does log error when request for state specific analysts emails failed', async () => { const mockEmailParameterStore = mockEmailParameterStoreError() const consoleErrorSpy = jest.spyOn(console, 'error') - const stateServer = await constructTestPostgresServer() + const stateServer = await constructTestPostgresServer({ + ldService: mockLDService, + }) const error = { error: 'No store found', message: 'getStateAnalystsEmails failed', @@ -832,6 +918,7 @@ describe.each(flagValueTestParameters)( context: { user: cmsUser, }, + ldService: mockLDService, emailParameterStore: mockEmailParameterStore, }) diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts index 39e664e0e7..03fd65a08e 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts @@ -4,15 +4,21 @@ import type { LockedHealthPlanFormDataType, } from '../../../../app-web/src/common-code/healthPlanFormDataType' import { toDomain } from '../../../../app-web/src/common-code/proto/healthPlanFormDataProto' -import type { UpdateInfoType, HealthPlanPackageType } from '../../domain-models' +import type { + UpdateInfoType, + HealthPlanPackageType, + ContractType, +} from '../../domain-models' import { isCMSUser, + convertContractWithRatesToUnlockedHPP, packageStatus, packageSubmitters, } from '../../domain-models' import type { Emailer } from '../../emailer' import type { MutationResolvers } from '../../gen/gqlServer' import { logError, logSuccess } from '../../logger' +import { NotFoundError } from '../../postgres' import type { Store } from '../../postgres' import { isStoreError } from '../../postgres' import { @@ -70,11 +76,128 @@ export function unlockHealthPlanPackageResolver( throw new ForbiddenError('user not authorized to unlock package') } + let unlockedPackage: HealthPlanPackageType | undefined = undefined + if (ratesDatabaseRefactor) { - throw new Error('Not Implemented') + const contractResult = await store.findContractWithHistory(pkgID) + + if (contractResult instanceof Error) { + if (contractResult instanceof NotFoundError) { + const errMessage = `A package must exist to be unlocked: ${pkgID}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage, { + argumentName: 'pkgID', + }) + } + + const errMessage = `Issue finding a package. Message: ${contractResult.message}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + const contract: ContractType = contractResult + + if (contract.draftRevision) { + const errMessage = `Attempted to unlock package with wrong status` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage, { + argumentName: 'pkgID', + cause: 'INVALID_PACKAGE_STATUS', + }) + } + + // unlock all the revisions, then unlock the contract, in a transaction. + const currentRateRevIDs = contract.revisions[0].rateRevisions.map( + (rr) => rr.id + ) + const unlockRatePromises = [] + for (const rateRevisionID of currentRateRevIDs) { + const resPromise = store.unlockRate({ + rateRevisionID, + unlockReason: unlockedReason, + unlockedByUserID: user.id, + }) + + unlockRatePromises.push(resPromise) + } + + const unlockRateResults = await Promise.all(unlockRatePromises) + // if any of the promises reject, which shouldn't happen b/c we don't throw... + if (unlockRateResults instanceof Error) { + const errMessage = `Failed to unlock contract rates with ID: ${contract.id}; ${unlockRateResults.message}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + const unlockRateErrors: Error[] = unlockRateResults.filter( + (res) => res instanceof Error + ) as Error[] + if (unlockRateErrors.length > 0) { + console.error('Errors unlocking Rates: ', unlockRateErrors) + const errMessage = `Failed to submit contract revision's rates with ID: ${ + contract.id + }; ${unlockRateErrors.map((err) => err.message)}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + // Now, unlock the contract! + const unlockContractResult = await store.unlockContract({ + contractID: contract.id, + unlockReason: unlockedReason, + unlockedByUserID: user.id, + }) + if (unlockContractResult instanceof Error) { + const errMessage = `Failed to unlock contract revision with ID: ${contract.id}; ${unlockContractResult.message}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + const unlockedPKGResult = + convertContractWithRatesToUnlockedHPP(unlockContractResult) + + if (unlockedPKGResult instanceof Error) { + const errMessage = `Error converting draft contract. Message: ${unlockedPKGResult.message}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'PROTO_DECODE_ERROR', + }, + }) + } + + // set variables used across feature flag boundary + unlockedPackage = unlockedPKGResult } else { // pre-rates refactor code path - // fetch from the store const result = await store.findHealthPlanPackage(pkgID) @@ -109,11 +232,9 @@ export function unlockHealthPlanPackageResolver( 'Attempted to unlock package with wrong status' logError('unlockHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'INVALID_PACKAGE_STATUS', - }, + throw new UserInputError(errMessage, { + argumentName: 'pkgID', + cause: 'INVALID_PACKAGE_STATUS', }) } @@ -151,14 +272,14 @@ export function unlockHealthPlanPackageResolver( updatedReason: unlockedReason, } - const unlockedPackage = await store.insertHealthPlanRevision( + const unlockedPkg = await store.insertHealthPlanRevision( pkgID, updateInfo, draftformData ) - if (isStoreError(unlockedPackage)) { - const errMessage = `Issue unlocking a package of type ${unlockedPackage.code}. Message: ${unlockedPackage.message}` + if (isStoreError(unlockedPkg)) { + const errMessage = `Issue unlocking a package of type ${unlockedPkg.code}. Message: ${unlockedPkg.message}` logError('unlockHealthPlanPackage', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) throw new GraphQLError(errMessage, { @@ -169,87 +290,119 @@ export function unlockHealthPlanPackageResolver( }) } - // Send emails! + unlockedPackage = unlockedPkg + } - // Get state analysts emails from parameter store - let stateAnalystsEmails = - await emailParameterStore.getStateAnalystsEmails( - draftformData.stateCode - ) - //If error, log it and set stateAnalystsEmails to empty string as to not interrupt the emails. - if (stateAnalystsEmails instanceof Error) { - logError('getStateAnalystsEmails', stateAnalystsEmails.message) - setErrorAttributesOnActiveSpan( - stateAnalystsEmails.message, - span - ) - stateAnalystsEmails = [] - } + // Send emails! + + const formDataResult = toDomain( + unlockedPackage.revisions[0].formDataProto + ) + if (formDataResult instanceof Error) { + const errMessage = `Couldn't unbox unlocked proto. Message: ${formDataResult.message}` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + if (formDataResult.status === 'SUBMITTED') { + const errMessage = `Programming Error: Got SUBMITTED from an unlocked pkg.` + logError('unlockHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } - // Get submitter email from every pkg submitted revision. - const submitterEmails = packageSubmitters(unlockedPackage) + const draftformData: UnlockedHealthPlanFormDataType = formDataResult - const statePrograms = store.findStatePrograms( + // Get state analysts emails from parameter store + let stateAnalystsEmails = + await emailParameterStore.getStateAnalystsEmails( draftformData.stateCode ) + //If error, log it and set stateAnalystsEmails to empty string as to not interrupt the emails. + if (stateAnalystsEmails instanceof Error) { + logError('getStateAnalystsEmails', stateAnalystsEmails.message) + setErrorAttributesOnActiveSpan(stateAnalystsEmails.message, span) + stateAnalystsEmails = [] + } - 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', - }, - }) - } + // Get submitter email from every pkg submitted revision. + const submitterEmails = packageSubmitters(unlockedPackage) - const unlockPackageCMSEmailResult = - await emailer.sendUnlockPackageCMSEmail( - draftformData, - updateInfo, - stateAnalystsEmails, - statePrograms - ) + const statePrograms = store.findStatePrograms(draftformData.stateCode) - const unlockPackageStateEmailResult = - await emailer.sendUnlockPackageStateEmail( - draftformData, - updateInfo, - statePrograms, - submitterEmails - ) + 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', + }, + }) + } - if ( - unlockPackageCMSEmailResult instanceof Error || - unlockPackageStateEmailResult instanceof Error - ) { - if (unlockPackageCMSEmailResult instanceof Error) { - logError( - 'unlockPackageCMSEmail - CMS email failed', - unlockPackageCMSEmailResult - ) - setErrorAttributesOnActiveSpan('CMS email failed', span) - } - if (unlockPackageStateEmailResult instanceof Error) { - logError( - 'unlockPackageStateEmail - state email failed', - unlockPackageStateEmailResult - ) - setErrorAttributesOnActiveSpan('state email failed', span) - } - throw new GraphQLError('Email failed.', { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'EMAIL_ERROR', - }, - }) - } + const updateInfo: UpdateInfoType = { + updatedAt: new Date(), // technically this is not right but it's close enough while we are supporting two systems + updatedBy: context.user.email, + updatedReason: unlockedReason, + } - logSuccess('unlockHealthPlanPackage') - setSuccessAttributesOnActiveSpan(span) + const unlockPackageCMSEmailResult = + await emailer.sendUnlockPackageCMSEmail( + draftformData, + updateInfo, + stateAnalystsEmails, + statePrograms + ) + + const unlockPackageStateEmailResult = + await emailer.sendUnlockPackageStateEmail( + draftformData, + updateInfo, + statePrograms, + submitterEmails + ) - return { pkg: unlockedPackage } + if ( + unlockPackageCMSEmailResult instanceof Error || + unlockPackageStateEmailResult instanceof Error + ) { + if (unlockPackageCMSEmailResult instanceof Error) { + logError( + 'unlockPackageCMSEmail - CMS email failed', + unlockPackageCMSEmailResult + ) + setErrorAttributesOnActiveSpan('CMS email failed', span) + } + if (unlockPackageStateEmailResult instanceof Error) { + logError( + 'unlockPackageStateEmail - state email failed', + unlockPackageStateEmailResult + ) + setErrorAttributesOnActiveSpan('state email failed', span) + } + throw new GraphQLError('Email failed.', { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'EMAIL_ERROR', + }, + }) } + + logSuccess('unlockHealthPlanPackage') + setSuccessAttributesOnActiveSpan(span) + + return { pkg: unlockedPackage } } } diff --git a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts index 10672f3ce9..20a68ee448 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.test.ts @@ -28,7 +28,7 @@ import type { HealthPlanFormDataType, RateInfoType, StateCodeType, -} from 'app-web/src/common-code/healthPlanFormDataType' +} from '../../../../app-web/src/common-code/healthPlanFormDataType' import * as add_sha from '../../handlers/add_sha' const flagValueTestParameters: { diff --git a/services/app-api/src/testHelpers/contractAndRates/contractHelpers.ts b/services/app-api/src/testHelpers/contractAndRates/contractHelpers.ts index 3464c0c8e0..1c1ac0dcc5 100644 --- a/services/app-api/src/testHelpers/contractAndRates/contractHelpers.ts +++ b/services/app-api/src/testHelpers/contractAndRates/contractHelpers.ts @@ -4,7 +4,7 @@ import type { ContractRevisionTableWithRates, ContractTableFullPayload, } from '../../postgres/contractAndRates/prismaSubmittedContractHelpers' -import type { StateCodeType } from 'app-web/src/common-code/healthPlanFormDataType' +import type { StateCodeType } from '../../../../app-web/src/common-code/healthPlanFormDataType' import type { ContractFormDataType } from '../../domain-models/contractAndRates' import { getProgramsFromState } from '../stateHelpers' @@ -104,6 +104,7 @@ const createContractRevision = ( supportingDocuments: [ { id: uuidv4(), + position: 0, contractRevisionID: 'contractRevisionID', createdAt: new Date(), updatedAt: new Date(), @@ -113,6 +114,7 @@ const createContractRevision = ( }, { id: uuidv4(), + position: 1, contractRevisionID: 'contractRevisionID', createdAt: new Date(), updatedAt: new Date(), @@ -126,6 +128,7 @@ const createContractRevision = ( contractDocuments: [ { id: uuidv4(), + position: 0, contractRevisionID: 'contractRevisionID', createdAt: new Date(), updatedAt: new Date(), diff --git a/services/app-api/src/testHelpers/contractAndRates/rateHelpers.ts b/services/app-api/src/testHelpers/contractAndRates/rateHelpers.ts index 067a88dc1b..4cbb296dc7 100644 --- a/services/app-api/src/testHelpers/contractAndRates/rateHelpers.ts +++ b/services/app-api/src/testHelpers/contractAndRates/rateHelpers.ts @@ -98,6 +98,7 @@ const createRateRevision = ( supportingDocuments: [ { id: uuidv4(), + position: 0, rateRevisionID: 'rateRevisionID', createdAt: new Date(), updatedAt: new Date(), @@ -107,6 +108,7 @@ const createRateRevision = ( }, { id: uuidv4(), + position: 1, rateRevisionID: 'rateRevisionID', createdAt: new Date(), updatedAt: new Date(), @@ -118,6 +120,7 @@ const createRateRevision = ( rateDocuments: [ { id: uuidv4(), + position: 0, rateRevisionID: 'rateRevisionID', createdAt: new Date(), updatedAt: new Date(), diff --git a/services/app-api/src/testHelpers/storeHelpers.ts b/services/app-api/src/testHelpers/storeHelpers.ts index 14a0684bf0..2bcbf79c47 100644 --- a/services/app-api/src/testHelpers/storeHelpers.ts +++ b/services/app-api/src/testHelpers/storeHelpers.ts @@ -122,11 +122,21 @@ function mockStoreThatErrors(): Store { 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' ) }, + unlockContract: async (_ID) => { + return new Error( + 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' + ) + }, submitRate: async (_ID) => { return new Error( 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' ) }, + unlockRate: async (_ID) => { + return new Error( + 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' + ) + }, } } diff --git a/services/app-web/src/common-code/healthPlanFormDataType/HealthPlanFormDataType.d.ts b/services/app-web/src/common-code/healthPlanFormDataType/HealthPlanFormDataType.ts similarity index 100% rename from services/app-web/src/common-code/healthPlanFormDataType/HealthPlanFormDataType.d.ts rename to services/app-web/src/common-code/healthPlanFormDataType/HealthPlanFormDataType.ts diff --git a/services/app-web/src/common-code/healthPlanFormDataType/LockedHealthPlanFormDataType.d.ts b/services/app-web/src/common-code/healthPlanFormDataType/LockedHealthPlanFormDataType.ts similarity index 94% rename from services/app-web/src/common-code/healthPlanFormDataType/LockedHealthPlanFormDataType.d.ts rename to services/app-web/src/common-code/healthPlanFormDataType/LockedHealthPlanFormDataType.ts index 8b9db2ca94..93ecad5dfc 100644 --- a/services/app-web/src/common-code/healthPlanFormDataType/LockedHealthPlanFormDataType.d.ts +++ b/services/app-web/src/common-code/healthPlanFormDataType/LockedHealthPlanFormDataType.ts @@ -1,8 +1,9 @@ +import type { FederalAuthority } from './FederalAuthorities' + // StateSubmission is a health plan that has been submitted to CMS. import type { StateContact, ActuaryContact, - FederalAuthority, SubmissionDocument, ContractAmendmentInfo, ActuaryCommunicationType, @@ -27,7 +28,7 @@ export type LockedHealthPlanFormDataType = { populationCovered?: PopulationCoveredType submissionType: SubmissionType createdAt: Date - updatedAt: DateTime + updatedAt: Date documents: SubmissionDocument[] contractType: ContractType contractExecutionStatus: ContractExecutionStatus diff --git a/services/app-web/src/common-code/healthPlanFormDataType/UnlockedHealthPlanFormDataType.d.ts b/services/app-web/src/common-code/healthPlanFormDataType/UnlockedHealthPlanFormDataType.ts similarity index 91% rename from services/app-web/src/common-code/healthPlanFormDataType/UnlockedHealthPlanFormDataType.d.ts rename to services/app-web/src/common-code/healthPlanFormDataType/UnlockedHealthPlanFormDataType.ts index 30341682db..437ef73644 100644 --- a/services/app-web/src/common-code/healthPlanFormDataType/UnlockedHealthPlanFormDataType.d.ts +++ b/services/app-web/src/common-code/healthPlanFormDataType/UnlockedHealthPlanFormDataType.ts @@ -1,4 +1,5 @@ -import { FederalAuthority } from 'FederalAuthority' +import { FederalAuthority } from './FederalAuthorities' +import { GeneralizedModifiedProvisions } from './ModifiedProvisions' // Draft state submission is a health plan that a state user is still working on @@ -24,6 +25,10 @@ type ContractAmendmentInfo = { modifiedProvisions: GeneralizedModifiedProvisions } +type UnlockedContractAmendmentInfo = { + modifiedProvisions: Partial +} + type RateAmendmentInfo = { effectiveDateStart?: Date effectiveDateEnd?: Date @@ -111,7 +116,7 @@ type UnlockedHealthPlanFormDataType = { contractDateEnd?: Date managedCareEntities: ManagedCareEntity[] federalAuthorities: FederalAuthority[] - contractAmendmentInfo?: ContractAmendmentInfo + contractAmendmentInfo?: UnlockedContractAmendmentInfo rateInfos: RateInfoType[] } diff --git a/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.test.ts b/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.test.ts index 7edacbbe28..4fff671752 100644 --- a/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.test.ts +++ b/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.test.ts @@ -204,11 +204,11 @@ describe('submission type assertions', () => { rateInfos: [ { rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], }, ], rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], }, false, ], @@ -565,7 +565,7 @@ describe('submission type assertions', () => { effectiveDateEnd: new Date('2022/09/21'), }, rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], rateProgramIDs: [ 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', ], @@ -588,7 +588,7 @@ describe('submission type assertions', () => { rateDateEnd: new Date('2022/03/29'), rateDateCertified: new Date('2021/04/22'), rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], rateProgramIDs: [ 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', ], @@ -618,7 +618,7 @@ describe('submission type assertions', () => { 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', ], rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], actuaryContacts: [], packagesWithSharedRateCerts: [], }, @@ -645,7 +645,7 @@ describe('submission type assertions', () => { 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', ], rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], actuaryContacts: [], packagesWithSharedRateCerts: [], }, @@ -665,7 +665,7 @@ describe('submission type assertions', () => { 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', ], rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], actuaryContacts: [], packagesWithSharedRateCerts: [], }, @@ -691,7 +691,7 @@ describe('submission type assertions', () => { 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', ], rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], actuaryContacts: [], packagesWithSharedRateCerts: [], }, @@ -712,7 +712,7 @@ describe('submission type assertions', () => { effectiveDateStart: new Date('2022/05/21'), }, rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], rateProgramIDs: [ 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', ], @@ -738,7 +738,7 @@ describe('submission type assertions', () => { effectiveDateEnd: new Date('2022/09/21'), }, rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], rateProgramIDs: [ 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', ], @@ -768,7 +768,7 @@ describe('submission type assertions', () => { effectiveDateEnd: new Date('2022/09/21'), }, rateDocuments: [], - supportingDocuments: [], + supportingDocuments: [], rateProgramIDs: [], actuaryContacts: [], packagesWithSharedRateCerts: [], @@ -803,6 +803,7 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -811,6 +812,7 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also_2.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: [ 'RATES_RELATED' as const, 'CONTRACT_RELATED' as const, @@ -819,6 +821,7 @@ describe('submission type assertions', () => { { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, ], @@ -830,16 +833,19 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, { name: 'contract_supporting_that_applies_to_a_rate_also_2.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, ], @@ -853,6 +859,7 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: [ 'RATES_RELATED' as const, 'CONTRACT_RELATED' as const, @@ -861,6 +868,7 @@ describe('submission type assertions', () => { { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, ], @@ -872,11 +880,13 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, ], @@ -890,6 +900,7 @@ describe('submission type assertions', () => { { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, ], @@ -901,6 +912,7 @@ describe('submission type assertions', () => { { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, ], @@ -922,6 +934,7 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -930,6 +943,7 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also_2.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: [ 'RATES_RELATED' as const, 'CONTRACT_RELATED' as const, @@ -938,11 +952,13 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also_3.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, ] @@ -951,21 +967,25 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, { name: 'contract_supporting_that_applies_to_a_rate_also_2.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, { name: 'contract_supporting_that_applies_to_a_rate_also_3.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, { name: 'rate_only_supporting_doc.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, ]) @@ -976,6 +996,7 @@ describe('submission type assertions', () => { { name: 'contract_certification.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['CONTRACT' as const], }, ] @@ -984,6 +1005,7 @@ describe('submission type assertions', () => { { name: 'rates_certification.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: ['RATES' as const], }, ] @@ -992,6 +1014,7 @@ describe('submission type assertions', () => { { name: 'contract_supporting_that_applies_to_a_rate_also.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -1000,6 +1023,7 @@ describe('submission type assertions', () => { { name: 'rates_certification.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: [ 'RATES' as const, 'RATES_RELATED' as const, @@ -1008,6 +1032,7 @@ describe('submission type assertions', () => { { name: 'contract_certification.pdf', s3URL: 'fakeS3URL', + sha256: 'fakesha', documentCategories: [ 'CONTRACT' as const, 'CONTRACT_RELATED' as const, diff --git a/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.ts b/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.ts index f5ea77f2f7..65b6d15dcd 100644 --- a/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.ts +++ b/services/app-web/src/common-code/healthPlanFormDataType/healthPlanFormData.ts @@ -15,7 +15,10 @@ import { formatRateNameDate } from '../../common-code/dateHelpers' import type { LockedHealthPlanFormDataType } from './LockedHealthPlanFormDataType' import type { HealthPlanFormDataType } from './HealthPlanFormDataType' import type { ProgramArgType } from '.' -import { federalAuthorityKeysForCHIP } from './FederalAuthorities' +import { + CHIPFederalAuthority, + federalAuthorityKeysForCHIP, +} from './FederalAuthorities' // TODO: Refactor into multiple files and add unit tests to these functions @@ -358,7 +361,9 @@ const removeInvalidProvisionsAndAuthorities = ( // remove invalid authorities if CHIP if (isCHIPOnly(pkg)) { pkg.federalAuthorities = pkg.federalAuthorities.filter((authority) => - federalAuthorityKeysForCHIP.includes(authority) + federalAuthorityKeysForCHIP.includes( + authority as CHIPFederalAuthority + ) ) } diff --git a/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts b/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts index 10c2d36781..0c3313191b 100644 --- a/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts +++ b/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts @@ -190,7 +190,7 @@ function parseProtoDocuments( mcreviewproto.DocumentCategory, doc.documentCategories ) as DocumentCategoryType[], - sha256: doc.sha256 || undefined, + sha256: doc.sha256, })) } diff --git a/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.test.tsx b/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.test.tsx index 5288bcbdd7..7269767d70 100644 --- a/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.test.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/ContractDetailsSummarySection/ContractDetailsSummarySection.test.tsx @@ -17,11 +17,13 @@ describe('ContractDetailsSummarySection', () => { { s3URL: 's3://bucketname/key/test1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://bucketname/key/test3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -166,6 +168,7 @@ describe('ContractDetailsSummarySection', () => { { s3URL: 's3://foo/bar/contract', name: 'contract test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT' as const], }, ], @@ -173,16 +176,19 @@ describe('ContractDetailsSummarySection', () => { { s3URL: 's3://bucketname/key/test1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://bucketname/key/test2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://bucketname/key/test3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -338,18 +344,18 @@ describe('ContractDetailsSummarySection', () => { await screen.findByText('1115 Waiver Authority') ).toBeInTheDocument() expect( - await screen.queryByText('1932(a) State Plan Authority') + screen.queryByText('1932(a) State Plan Authority') ).not.toBeInTheDocument() expect( - await screen.queryByText('1937 Benchmark Authority') + screen.queryByText('1937 Benchmark Authority') ).not.toBeInTheDocument() }) it('renders inline error when bulk URL is unavailable', async () => { const s3Provider = { ...testS3Client(), getBulkDlURL: async ( - keys: string[], - fileName: string + _keys: string[], + _fileName: string ): Promise => { return new Error('Error: getBulkDlURL encountered an error') }, @@ -601,9 +607,7 @@ describe('ContractDetailsSummarySection', () => { const contractWithUnansweredProvisions: UnlockedHealthPlanFormDataType = { ...mockContractAndRatesDraft(), - contractAmendmentInfo: { - modifiedProvisions: undefined, - }, + contractAmendmentInfo: undefined, } renderWithProviders( { @@ -78,7 +78,9 @@ export const ContractDetailsSummarySection = ({ const isEditing = !isSubmitted(submission) && navigateTo !== undefined const applicableFederalAuthorities = isCHIPOnly(submission) ? submission.federalAuthorities.filter((authority) => - federalAuthorityKeysForCHIP.includes(authority) + federalAuthorityKeysForCHIP.includes( + authority as CHIPFederalAuthority + ) ) : submission.federalAuthorities const [modifiedProvisions, unmodifiedProvisions] = diff --git a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.test.tsx b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.test.tsx index 54fcfb043e..6f094729a8 100644 --- a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.test.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/RateDetailsSummarySection.test.tsx @@ -22,6 +22,7 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/rate', name: 'rate docs test 1', + sha256: 'fakesha', documentCategories: ['RATES' as const], }, ], @@ -51,6 +52,7 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/rate2', name: 'rate docs test 2', + sha256: 'fakesha', documentCategories: ['RATES' as const], }, ], @@ -257,6 +259,7 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/rate', name: 'rate docs test 1', + sha256: 'fakesha', documentCategories: ['RATES' as const], }, ], @@ -266,16 +269,19 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -568,6 +574,7 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/rate', name: 'rate docs test 1', + sha256: 'fakesha', documentCategories: ['RATES' as const], }, ], @@ -577,16 +584,19 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -665,6 +675,7 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/rate', name: 'rate docs test 1', + sha256: 'fakesha', documentCategories: ['RATES' as const], }, ], @@ -674,16 +685,19 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -738,6 +752,7 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/rate', name: 'rate docs test 1', + sha256: 'fakesha', documentCategories: ['RATES' as const], }, ], @@ -747,16 +762,19 @@ describe('RateDetailsSummarySection', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -808,8 +826,8 @@ describe('RateDetailsSummarySection', () => { const s3Provider = { ...testS3Client(), getBulkDlURL: async ( - keys: string[], - fileName: string + _keys: string[], + _fileName: string ): Promise => { return new Error('Error: getBulkDlURL encountered an error') }, diff --git a/services/app-web/src/components/SubmissionSummarySection/SupportingDocumentsSummarySection/SupportingDocumentsSummarySection.test.tsx b/services/app-web/src/components/SubmissionSummarySection/SupportingDocumentsSummarySection/SupportingDocumentsSummarySection.test.tsx index 26bb92a583..a3847971a8 100644 --- a/services/app-web/src/components/SubmissionSummarySection/SupportingDocumentsSummarySection/SupportingDocumentsSummarySection.test.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/SupportingDocumentsSummarySection/SupportingDocumentsSummarySection.test.tsx @@ -18,16 +18,19 @@ describe('SupportingDocumentsSummarySection', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [], }, ], @@ -69,11 +72,13 @@ describe('SupportingDocumentsSummarySection', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: [], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: [], }, ], diff --git a/services/app-web/src/components/SubmissionSummarySection/UploadedDocumentsTable/UploadedDocumentsTable.test.tsx b/services/app-web/src/components/SubmissionSummarySection/UploadedDocumentsTable/UploadedDocumentsTable.test.tsx index bea8133808..8fc89f3ff6 100644 --- a/services/app-web/src/components/SubmissionSummarySection/UploadedDocumentsTable/UploadedDocumentsTable.test.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/UploadedDocumentsTable/UploadedDocumentsTable.test.tsx @@ -17,6 +17,7 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, ] @@ -51,11 +52,13 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -101,16 +104,19 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -159,16 +165,19 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -219,16 +228,19 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -279,16 +291,19 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -339,16 +354,19 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -393,16 +411,19 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-1', name: 'supporting docs test 1', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED' as const], }, { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', + sha256: 'fakesha', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, diff --git a/services/app-web/src/documentHelpers/makeDocumentDateLookupTable.test.ts b/services/app-web/src/documentHelpers/makeDocumentDateLookupTable.test.ts index 396b30b040..20e32fd386 100644 --- a/services/app-web/src/documentHelpers/makeDocumentDateLookupTable.test.ts +++ b/services/app-web/src/documentHelpers/makeDocumentDateLookupTable.test.ts @@ -63,6 +63,7 @@ describe('makeDocumentDateTable', () => { { s3URL: 's3://bucketname/testDateDoc/testDateDoc.pdf', name: 'Test Date Doc', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, ], @@ -70,6 +71,7 @@ describe('makeDocumentDateTable', () => { { s3URL: 's3://bucketname/key/replaced-contract.pdf', name: 'replaced contract', + sha256: 'fakesha', documentCategories: ['CONTRACT'], }, ], @@ -101,6 +103,7 @@ describe('makeDocumentDateTable', () => { { s3URL: 's3://bucketname/key/original-contract.pdf', name: 'original contract', + sha256: 'fakesha', documentCategories: ['CONTRACT'], }, ], diff --git a/services/app-web/src/formHelpers/formatters.ts b/services/app-web/src/formHelpers/formatters.ts index d70b156848..87d53b582b 100644 --- a/services/app-web/src/formHelpers/formatters.ts +++ b/services/app-web/src/formHelpers/formatters.ts @@ -88,11 +88,15 @@ const formatDocumentsForDomain = ( console.info( 'Attempting to save files that are duplicate names, discarding duplicate' ) - } else if (!fileItem.s3URL) + } else if (!fileItem.s3URL) { console.info( 'Attempting to save a seemingly valid file item is not yet uploaded to S3, this should not happen on form submit. Discarding file.' ) - else { + } else if (!fileItem.sha256) { + console.info( + 'Attempting to save a seemingly valid file item does not have a sha256 yet. this should not happen on form submit. Discarding file.' + ) + } else { cleanedFileItems.push({ name: fileItem.name, s3URL: fileItem.s3URL, diff --git a/services/app-web/src/pages/QuestionResponse/QuestionResponse.tsx b/services/app-web/src/pages/QuestionResponse/QuestionResponse.tsx index 58dfebe229..9ea5a5c05f 100644 --- a/services/app-web/src/pages/QuestionResponse/QuestionResponse.tsx +++ b/services/app-web/src/pages/QuestionResponse/QuestionResponse.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import { useEffect } from 'react' import { GridContainer, Link } from '@trussworks/react-uswds' import styles from './QuestionResponse.module.scss' @@ -12,7 +12,6 @@ import { } from '../../components/Banner' import { QATable, QuestionData, Division } from './QATable/QATable' import { CmsUser, QuestionEdge, StateUser } from '../../gen/gqlClient' -import { CMSUserType } from 'app-api/src/domain-models' import { useStringConstants } from '../../hooks/useStringConstants' type divisionQuestionDataType = { @@ -34,8 +33,12 @@ const extractQuestions = (edges?: QuestionEdge[]): QuestionData[] => { })) } -const getUserDivision = (user: CMSUserType): Division | undefined => - user.divisionAssignment +const getUserDivision = (user: CmsUser): Division | undefined => { + if (user.divisionAssignment) { + return user.divisionAssignment + } + return undefined +} const getDivisionOrder = (division?: Division): Division[] => ['DMCO', 'DMCP', 'OACT'].sort((a, b) => { @@ -76,7 +79,7 @@ export const QuestionResponse = () => { const isCMSUser = user?.role === 'CMS_USER' if (isCMSUser) { - division = getUserDivision(user as CMSUserType) + division = getUserDivision(user as CmsUser) } const divisionOrder = getDivisionOrder(division) diff --git a/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.test.tsx b/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.test.tsx index 8e4facd193..21f2e33ca7 100644 --- a/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.test.tsx +++ b/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.test.tsx @@ -846,6 +846,7 @@ describe('ContractDetails', () => { contractDocuments: [ { name: 'aasdf3423af', + sha256: 'fakesha', s3URL: 's3://bucketname/key/fileName', documentCategories: ['CONTRACT' as const], }, diff --git a/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.tsx b/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.tsx index ff9409e7cc..6072bc6cfb 100644 --- a/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.tsx +++ b/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.tsx @@ -359,11 +359,15 @@ export const ContractDetails = ({ console.info( 'Attempting to save files that are duplicate names, discarding duplicate' ) - } else if (!fileItem.s3URL) + } else if (!fileItem.s3URL) { console.info( 'Attempting to save a seemingly valid file item is not yet uploaded to S3, this should not happen on form submit. Discarding file.' ) - else { + } else if (!fileItem.sha256) { + console.info( + 'Attempting to save a seemingly valid file item with no sha. this should not happen on form submit. Discarding file.' + ) + } else { formDataDocuments.push({ name: fileItem.name, s3URL: fileItem.s3URL, diff --git a/services/app-web/src/pages/StateSubmission/Documents/Documents.test.tsx b/services/app-web/src/pages/StateSubmission/Documents/Documents.test.tsx index 33dcbbbf9e..69ecd92d98 100644 --- a/services/app-web/src/pages/StateSubmission/Documents/Documents.test.tsx +++ b/services/app-web/src/pages/StateSubmission/Documents/Documents.test.tsx @@ -301,9 +301,7 @@ describe('Documents', () => { // Remove duplicate document and remove error await userEvent.click(screen.queryAllByText(/Remove/)[0]) - expect( - await screen.queryAllByText(TEST_DOC_FILE.name) - ).toHaveLength(1) + expect(screen.queryAllByText(TEST_DOC_FILE.name)).toHaveLength(1) expect( screen.queryByText('Duplicate file, please remove') ).toBeNull() @@ -956,6 +954,7 @@ describe('Documents', () => { { s3URL: 's3://bucketname/key/supporting-documents', name: 'supporting documents', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, ], @@ -990,6 +989,7 @@ describe('Documents', () => { { s3URL: 's3://bucketname/key/supporting-documents', name: 'supporting documents', + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, ], @@ -1120,7 +1120,7 @@ describe('Documents', () => { { s3URL: 's3://bucketname/key/supporting-documents', name: 'supporting documents', - sha256: undefined, + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, ], @@ -1145,8 +1145,8 @@ describe('Documents', () => { ).toBeInTheDocument() }) - expect(await screen.queryByText('Contract-supporting')).toBeNull() - expect(await screen.queryByText('Rate-supporting')).toBeNull() + expect(screen.queryByText('Contract-supporting')).toBeNull() + expect(screen.queryByText('Rate-supporting')).toBeNull() jest.clearAllMocks() }) @@ -1219,7 +1219,7 @@ describe('Documents', () => { { s3URL: 's3://bucketname/key/supporting-documents', name: 'supporting documents', - sha256: undefined, + sha256: 'fakesha', documentCategories: ['RATES_RELATED' as const], }, ], diff --git a/services/app-web/src/pages/StateSubmission/Documents/Documents.tsx b/services/app-web/src/pages/StateSubmission/Documents/Documents.tsx index d0a8df7504..f1f5e1e908 100644 --- a/services/app-web/src/pages/StateSubmission/Documents/Documents.tsx +++ b/services/app-web/src/pages/StateSubmission/Documents/Documents.tsx @@ -230,11 +230,15 @@ export const Documents = ({ console.info( 'Attempting to save files that are duplicate names, discarding duplicate' ) - } else if (!fileItem.s3URL) + } else if (!fileItem.s3URL) { console.info( 'Attempting to save a seemingly valid file item is not yet uploaded to S3, this should not happen on form submit. Discarding file.' ) - else { + } else if (!fileItem.sha256) { + console.info( + 'Attempting to save a seemingly valid file item with no sha256, this should not happen on form submit. Discarding file.' + ) + } else { formDataDocuments.push({ name: fileItem.name, s3URL: fileItem.s3URL, diff --git a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.test.tsx b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.test.tsx index 57c0933151..30a1d1e04b 100644 --- a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.test.tsx +++ b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.test.tsx @@ -122,6 +122,7 @@ describe('StateSubmissionForm', () => { modifiedNetworkAdequacyStandards: false, modifiedLengthOfContract: false, modifiedNonRiskPaymentArrangements: false, + inLieuServicesAndSettings: false, }, }, }) @@ -301,6 +302,7 @@ describe('StateSubmissionForm', () => { { name: 'somedoc.pdf', s3URL: 's3://bucketName/key/somedoc.pdf', + sha256: 'fakesha', documentCategories: ['CONTRACT_RELATED'], }, ] diff --git a/services/app-web/src/testHelpers/apolloMocks/healthPlanFormDataMock.ts b/services/app-web/src/testHelpers/apolloMocks/healthPlanFormDataMock.ts index db0fba27de..6e715c5454 100644 --- a/services/app-web/src/testHelpers/apolloMocks/healthPlanFormDataMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/healthPlanFormDataMock.ts @@ -15,6 +15,7 @@ import { } from '../../common-code/healthPlanFormDataMocks' import { + HealthPlanFormDataType, LockedHealthPlanFormDataType, SubmissionDocument, UnlockedHealthPlanFormDataType, @@ -231,6 +232,7 @@ function mockStateSubmission(): LockedHealthPlanFormDataType { documents: [ { s3URL: 's3://bucketname/key/supporting-documents', + sha256: 'fakesha', name: 'supporting documents', documentCategories: ['CONTRACT_RELATED' as const], }, @@ -240,6 +242,7 @@ function mockStateSubmission(): LockedHealthPlanFormDataType { contractDocuments: [ { s3URL: 's3://bucketname/key/contract', + sha256: 'fakesha', name: 'contract', documentCategories: ['CONTRACT' as const], }, @@ -256,6 +259,15 @@ function mockStateSubmission(): LockedHealthPlanFormDataType { modifiedPassThroughPayments: false, modifiedPaymentsForMentalDiseaseInstitutions: false, modifiedNonRiskPaymentArrangements: true, + modifiedBenefitsProvided: true, + modifiedEnrollmentProcess: true, + modifiedMedicaidBeneficiaries: true, + modifiedGeoAreaServed: true, + modifiedGrevienceAndAppeal: true, + modifiedLengthOfContract: true, + modifiedNetworkAdequacyStandards: true, + modifiedMedicalLossRatioStandards: true, + modifiedOtherFinancialPaymentIncentive: true, }, }, managedCareEntities: ['PAHP'], @@ -267,6 +279,7 @@ function mockStateSubmission(): LockedHealthPlanFormDataType { rateDocuments: [ { s3URL: 's3://bucketname/key/rate', + sha256: 'fakesha', name: 'rate', documentCategories: ['RATES' as const], }, @@ -274,6 +287,7 @@ function mockStateSubmission(): LockedHealthPlanFormDataType { supportingDocuments: [ { s3URL: 's3://bucketname/key/supporting-documents', + sha256: 'fakesha', name: 'supporting documents', documentCategories: ['RATES_RELATED' as const], }, @@ -324,6 +338,7 @@ function mockStateSubmissionContractAmendment(): LockedHealthPlanFormDataType { documents: [ { s3URL: 's3://bucketname/key/supporting-documents', + sha256: 'fakesha', name: 'supporting documents', documentCategories: ['RATES_RELATED' as const], }, @@ -333,6 +348,7 @@ function mockStateSubmissionContractAmendment(): LockedHealthPlanFormDataType { contractDocuments: [ { s3URL: 's3://bucketname/key/contract', + sha256: 'fakesha', name: 'contract', documentCategories: ['CONTRACT' as const], }, @@ -369,6 +385,7 @@ function mockStateSubmissionContractAmendment(): LockedHealthPlanFormDataType { rateDocuments: [ { s3URL: 's3://bucketname/key/rate', + sha256: 'fakesha', name: 'rate', documentCategories: ['RATES' as const], }, @@ -438,7 +455,10 @@ function mockSubmittedHealthPlanPackage( ): HealthPlanPackage { // get a submitted DomainModel submission // turn it into proto - const submission = { ...basicLockedHealthPlanFormData(), ...submissionData } + const submission = { + ...basicLockedHealthPlanFormData(), + ...submissionData, + } as HealthPlanFormDataType const b64 = domainToBase64(submission) return { @@ -667,16 +687,19 @@ function mockUnlockedHealthPlanPackageWithDocuments(): HealthPlanPackage { const docs1: SubmissionDocument[] = [ { s3URL: 's3://bucketname/one-one/one-one.png', + sha256: 'fakesha', name: 'one one', documentCategories: ['CONTRACT_RELATED'], }, { s3URL: 's3://bucketname/one-two/one-two.png', + sha256: 'fakesha', name: 'one two', documentCategories: ['CONTRACT_RELATED'], }, { s3URL: 's3://bucketname/one-three/one-three.png', + sha256: 'fakesha', name: 'one three', documentCategories: ['CONTRACT_RELATED'], }, @@ -684,16 +707,19 @@ function mockUnlockedHealthPlanPackageWithDocuments(): HealthPlanPackage { const docs2: SubmissionDocument[] = [ { s3URL: 's3://bucketname/one-two/one-two.png', + sha256: 'fakesha', name: 'one two', documentCategories: ['CONTRACT_RELATED'], }, { s3URL: 's3://bucketname/one-three/one-three.png', + sha256: 'fakesha', name: 'one three', documentCategories: ['CONTRACT_RELATED'], }, { s3URL: 's3://bucketname/two-one/two-one.png', + sha256: 'fakesha', name: 'two one', documentCategories: ['CONTRACT_RELATED'], }, @@ -701,16 +727,19 @@ function mockUnlockedHealthPlanPackageWithDocuments(): HealthPlanPackage { const docs3: SubmissionDocument[] = [ { s3URL: 's3://bucketname/one-two/one-two.png', + sha256: 'fakesha', name: 'one two', documentCategories: ['CONTRACT_RELATED'], }, { s3URL: 's3://bucketname/two-one/two-one.png', + sha256: 'fakesha', name: 'two one', documentCategories: ['CONTRACT_RELATED'], }, { s3URL: 's3://bucketname/three-one/three-one.png', + sha256: 'fakesha', name: 'three one', documentCategories: ['CONTRACT_RELATED'], }, diff --git a/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts b/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts index 9702f12d68..ae4394e2e0 100644 --- a/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts @@ -148,11 +148,13 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ contractDocuments: [ { s3URL: 's3://bucketname/1648242632157-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['CONTRACT'], }, { s3URL: 's3://bucketname/1648490162641-lifeofgalileo.pdf/lifeofgalileo.pdf', + sha256: 'fakesha', name: 'lifeofgalileo.pdf', documentCategories: ['CONTRACT'], }, @@ -162,11 +164,13 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ rateDocuments: [ { s3URL: 's3://bucketname/1648242665634-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['RATES'], }, { s3URL: 's3://bucketname/1648242711421-Amerigroup Texas Inc copy.pdf/Amerigroup Texas Inc copy.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas Inc copy.pdf', documentCategories: ['RATES'], }, @@ -174,6 +178,7 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ supportingDocuments: [ { s3URL: 's3://bucketname/1648242873229-covid-ifc-2-flu-rsv-codes 5-5-2021.pdf/covid-ifc-2-flu-rsv-codes 5-5-2021.pdf', + sha256: 'fakesha', name: 'covid-ifc-2-flu-rsv-codes 5-5-2021.pdf', documentCategories: ['RATES_RELATED'], }, @@ -185,6 +190,7 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ documents: [ { s3URL: 's3://bucketname/1648242711421-529-10-0020-00003_Superior_Health Plan, Inc.pdf/529-10-0020-00003_Superior_Health Plan, Inc.pdf', + sha256: 'fakesha', name: '529-10-0020-00003_Superior_Health Plan, Inc.pdf', documentCategories: ['CONTRACT_RELATED'], }, @@ -194,16 +200,19 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ contractDocuments: [ { s3URL: 's3://bucketname/1648242632157-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['CONTRACT'], }, { s3URL: 's3://bucketname/1648242665634-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['CONTRACT'], }, { s3URL: 's3://bucketname/1648242711421-Amerigroup Texas Inc copy.pdf/Amerigroup Texas Inc copy.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas Inc copy.pdf', documentCategories: ['CONTRACT'], }, @@ -213,16 +222,19 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ rateDocuments: [ { s3URL: 's3://bucketname/1648242711421-529-10-0020-00003_Superior_Health Plan, Inc.pdf/529-10-0020-00003_Superior_Health Plan, Inc.pdf', + sha256: 'fakesha', name: '529-10-0020-00003_Superior_Health Plan, Inc.pdf', documentCategories: ['RATES'], }, { s3URL: 's3://bucketname/1648242873229-covid-ifc-2-flu-rsv-codes 5-5-2021.pdf/covid-ifc-2-flu-rsv-codes 5-5-2021.pdf', + sha256: 'fakesha', name: 'covid-ifc-2-flu-rsv-codes 5-5-2021.pdf', documentCategories: ['RATES'], }, { s3URL: 's3://bucketname/1648242632157-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['RATES'], }, @@ -230,6 +242,7 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ supportingDocuments: [ { s3URL: 's3://bucketname/1648242711421-529-10-0020-00003_Superior_Health Plan, Inc.pdf/529-10-0020-00003_Superior_Health Plan, Inc.pdf', + sha256: 'fakesha', name: '529-10-0020-00003_Superior_Health Plan, Inc.pdf', documentCategories: ['RATES_RELATED'], }, @@ -241,11 +254,13 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ documents: [ { s3URL: 's3://bucketname/1648242665634-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['CONTRACT_RELATED'], }, { s3URL: 's3://bucketname/1648242711421-Amerigroup Texas Inc copy.pdf/Amerigroup Texas Inc copy.pdf', + sha256: 'fakesha', name: 'Amerigroup Texas Inc copy.pdf', documentCategories: ['CONTRACT_RELATED'], }, From 1783217f82305fdff97daf82b515a7a9c3a51fd3 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Fri, 29 Sep 2023 12:09:59 -0700 Subject: [PATCH 18/23] get app-web tests passing --- ...ithALittleBitOfEverything-2023-09-28.proto | Bin 0 -> 978 bytes .../proto/healthPlanFormDataProto/toDomain.ts | 2 +- .../UploadedDocumentsTable.test.tsx | 44 +++++++++--------- .../makeDocumentDateLookupTable.test.ts | 34 +++++--------- .../ContractDetails/ContractDetails.test.tsx | 2 +- .../Documents/Documents.test.tsx | 4 +- .../SubmissionType/SubmissionType.test.tsx | 2 +- .../apolloMocks/healthPlanPackageGQLMock.ts | 24 +++++----- 8 files changed, 50 insertions(+), 62 deletions(-) create mode 100644 services/app-web/src/common-code/proto/healthPlanFormDataProto/testData/unlockedWithALittleBitOfEverything-2023-09-28.proto diff --git a/services/app-web/src/common-code/proto/healthPlanFormDataProto/testData/unlockedWithALittleBitOfEverything-2023-09-28.proto b/services/app-web/src/common-code/proto/healthPlanFormDataProto/testData/unlockedWithALittleBitOfEverything-2023-09-28.proto new file mode 100644 index 0000000000000000000000000000000000000000..fdca3d84e31aea01254153a82b4d46354c656623 GIT binary patch literal 978 zcmb7@&u-d45XL+Dp?5iPoxdI#x{^sfDn-bGy*C`T$Ar;1aLeXPDfd+!_6pf;*{DI8WB?e1v4`OP<~)*sXdtyle*jm}xWfA*rQCG$Ix z2qQ!pN5m)>QYXFo>EIx(e1D)NbE=^zi*G+a+|#6X?mRUtQpOy&#xUY$O(2t-95H6Q z2%DI*3bP33Vpw!)R=H*h3z?=vkZCzCsuGK!_>~A@+b+R)SQM1l)aDpjF1L~CxK%_Q z$`QAXim2GOjVZg7U+>oSK+uUE&e?Pnh9ln#o~mir_jN{tOnOQ^dL@GV!_cRm6Z*&E zhE8V_amf8?@LW|+N1o6dlx5U#J`=&4QRoNx-HGqe;P0>R7N8_DzuVr+rTnpP7_ z`AMOuNSc%b9iB;8s=_g};T$fcOX*sg!Hu*4_z2Q_*sh3tSL7Cmx+xPaoln-Aoz^WN zb#pu#dE37%nxyOGD^~l&> zsj;2VDl}q_D`}y=(x#LD3@drZ@t;Ivg5#jxX?0KHGgw^_Y#HUHoKe>}Q@Lzx)YzZ*bJ<@|!x1b1 literal 0 HcmV?d00001 diff --git a/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts b/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts index 0c3313191b..1ee2d82a73 100644 --- a/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts +++ b/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts @@ -190,7 +190,7 @@ function parseProtoDocuments( mcreviewproto.DocumentCategory, doc.documentCategories ) as DocumentCategoryType[], - sha256: doc.sha256, + sha256: doc.sha256 || 'sha_undefined_in_proto', })) } diff --git a/services/app-web/src/components/SubmissionSummarySection/UploadedDocumentsTable/UploadedDocumentsTable.test.tsx b/services/app-web/src/components/SubmissionSummarySection/UploadedDocumentsTable/UploadedDocumentsTable.test.tsx index 8fc89f3ff6..c65275f5dd 100644 --- a/services/app-web/src/components/SubmissionSummarySection/UploadedDocumentsTable/UploadedDocumentsTable.test.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/UploadedDocumentsTable/UploadedDocumentsTable.test.tsx @@ -171,13 +171,13 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', - sha256: 'fakesha', + sha256: 'fakesha1', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', - sha256: 'fakesha', + sha256: 'fakesha2', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -185,11 +185,11 @@ describe('UploadedDocumentsTable', () => { }, ] const dateLookupTable: DocumentDateLookupTableType = { - 's3://foo/bar/test-1': + fakesha: 'Fri Mar 25 2022 16:13:20 GMT-0500 (Central Daylight Time)', - 's3://foo/bar/test-2': + fakesha1: 'Sat Mar 26 2022 16:13:20 GMT-0500 (Central Daylight Time)', - 's3://foo/bar/test-3': + fakesha2: 'Sun Mar 27 2022 16:13:20 GMT-0500 (Central Daylight Time)', previousSubmissionDate: 'Sun Mar 26 2022 16:13:20 GMT-0500 (Central Daylight Time)', @@ -234,13 +234,13 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', - sha256: 'fakesha', + sha256: 'fakesha1', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', - sha256: 'fakesha', + sha256: 'fakesha2', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -248,11 +248,11 @@ describe('UploadedDocumentsTable', () => { }, ] const dateLookupTable: DocumentDateLookupTableType = { - 's3://foo/bar/test-1': + fakesha: 'Fri Mar 25 2022 16:13:20 GMT-0500 (Central Daylight Time)', - 's3://foo/bar/test-2': + fakesha1: 'Sat Mar 26 2022 16:13:20 GMT-0500 (Central Daylight Time)', - 's3://foo/bar/test-3': + fakesha2: 'Sun Mar 27 2022 16:13:20 GMT-0500 (Central Daylight Time)', previousSubmissionDate: 'Sun Mar 26 2022 16:13:20 GMT-0500 (Central Daylight Time)', @@ -297,13 +297,13 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', - sha256: 'fakesha', + sha256: 'fakesha1', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', - sha256: 'fakesha', + sha256: 'fakesha2', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -360,13 +360,13 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', - sha256: 'fakesha', + sha256: 'fakesha1', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', - sha256: 'fakesha', + sha256: 'fakesha2', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -374,11 +374,11 @@ describe('UploadedDocumentsTable', () => { }, ] const dateLookupTable: DocumentDateLookupTableType = { - 's3://foo/bar/test-1': + fakesha: 'Fri Mar 25 2022 16:13:20 GMT-0500 (Central Daylight Time)', - 's3://foo/bar/test-2': + fakesha1: 'Sat Mar 26 2022 16:13:20 GMT-0500 (Central Daylight Time)', - 's3://foo/bar/test-3': + fakesha2: 'Sun Mar 27 2022 16:13:20 GMT-0500 (Central Daylight Time)', previousSubmissionDate: 'Sun Mar 26 2022 16:13:20 GMT-0500 (Central Daylight Time)', @@ -417,13 +417,13 @@ describe('UploadedDocumentsTable', () => { { s3URL: 's3://foo/bar/test-2', name: 'supporting docs test 2', - sha256: 'fakesha', + sha256: 'fakesha1', documentCategories: ['RATES_RELATED' as const], }, { s3URL: 's3://foo/bar/test-3', name: 'supporting docs test 3', - sha256: 'fakesha', + sha256: 'fakesha2', documentCategories: [ 'CONTRACT_RELATED' as const, 'RATES_RELATED' as const, @@ -431,11 +431,11 @@ describe('UploadedDocumentsTable', () => { }, ] const dateLookupTable = { - 'supporting docs test 1': + fakesha: 'Fri Mar 25 2022 16:13:20 GMT-0500 (Central Daylight Time)', - 'supporting docs test 2': + fakesha1: 'Sat Mar 26 2022 16:13:20 GMT-0500 (Central Daylight Time)', - 'supporting docs test 3': + fakesha2: 'Sun Mar 27 2022 16:13:20 GMT-0500 (Central Daylight Time)', previousSubmissionDate: 'Sun Mar 26 2022 16:13:20 GMT-0500 (Central Daylight Time)', diff --git a/services/app-web/src/documentHelpers/makeDocumentDateLookupTable.test.ts b/services/app-web/src/documentHelpers/makeDocumentDateLookupTable.test.ts index 20e32fd386..1e0b8db184 100644 --- a/services/app-web/src/documentHelpers/makeDocumentDateLookupTable.test.ts +++ b/services/app-web/src/documentHelpers/makeDocumentDateLookupTable.test.ts @@ -28,18 +28,12 @@ describe('makeDocumentDateTable', () => { } const lookupTable = makeDocumentDateTable(revisionsLookup) expect(lookupTable).toEqual({ - 's3://bucketname/1648242632157-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf': - new Date('2022-03-25T21:13:20.420Z'), - 's3://bucketname/1648490162641-lifeofgalileo.pdf/lifeofgalileo.pdf': - new Date('2022-03-28T17:56:32.953Z'), - 's3://bucketname/1648242665634-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf': - new Date('2022-03-25T21:13:20.420Z'), - 's3://bucketname/1648242711421-Amerigroup Texas Inc copy.pdf/Amerigroup Texas Inc copy.pdf': - new Date('2022-03-25T21:13:20.420Z'), - 's3://bucketname/1648242711421-529-10-0020-00003_Superior_Health Plan, Inc.pdf/529-10-0020-00003_Superior_Health Plan, Inc.pdf': - new Date('2022-03-25T21:13:20.420Z'), - 's3://bucketname/1648242873229-covid-ifc-2-flu-rsv-codes 5-5-2021.pdf/covid-ifc-2-flu-rsv-codes 5-5-2021.pdf': - new Date('2022-03-25T21:13:20.420Z'), + fakesha: new Date('2022-03-25T21:13:20.420Z'), + fakesha1: new Date('2022-03-28T17:56:32.953Z'), + fakesha2: new Date('2022-03-25T21:13:20.420Z'), + fakesha3: new Date('2022-03-25T21:13:20.420Z'), + fakesha4: new Date('2022-03-25T21:13:20.420Z'), + fakesha5: new Date('2022-03-25T21:13:20.420Z'), previousSubmissionDate: new Date('2022-03-25T21:14:43.057Z'), }) }) @@ -71,7 +65,7 @@ describe('makeDocumentDateTable', () => { { s3URL: 's3://bucketname/key/replaced-contract.pdf', name: 'replaced contract', - sha256: 'fakesha', + sha256: 'fakesha1', documentCategories: ['CONTRACT'], }, ], @@ -103,7 +97,7 @@ describe('makeDocumentDateTable', () => { { s3URL: 's3://bucketname/key/original-contract.pdf', name: 'original contract', - sha256: 'fakesha', + sha256: 'fakesha2', documentCategories: ['CONTRACT'], }, ], @@ -120,15 +114,9 @@ describe('makeDocumentDateTable', () => { const lookupTable = makeDocumentDateTable(revisionsLookup) expect(lookupTable).toEqual({ - 's3://bucketname/key/original-contract.pdf': new Date( - '2022-01-10T00:00:00.000Z' - ), - 's3://bucketname/key/replaced-contract.pdf': new Date( - '2022-02-10T00:00:00.000Z' - ), - 's3://bucketname/testDateDoc/testDateDoc.pdf': new Date( - '2022-01-10T00:00:00.00' - ), + fakesha: new Date('2022-01-10T00:00:00.000Z'), + fakesha1: new Date('2022-02-10T00:00:00.000Z'), + fakesha2: new Date('2022-01-10T00:00:00.00'), previousSubmissionDate: new Date('2022-02-10T00:00:00.000Z'), }) }) diff --git a/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.test.tsx b/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.test.tsx index bc4a2e87ab..2c6121eac1 100644 --- a/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.test.tsx +++ b/services/app-web/src/pages/StateSubmission/ContractDetails/ContractDetails.test.tsx @@ -56,7 +56,7 @@ describe('ContractDetails', () => { ).not.toBeInTheDocument() const requiredLabels = await screen.findAllByText('Required') expect(requiredLabels).toHaveLength(6) - const optionalLabels = await screen.queryAllByText('Optional') + const optionalLabels = screen.queryAllByText('Optional') expect(optionalLabels).toHaveLength(0) }) diff --git a/services/app-web/src/pages/StateSubmission/Documents/Documents.test.tsx b/services/app-web/src/pages/StateSubmission/Documents/Documents.test.tsx index 69ecd92d98..52c9b24ca6 100644 --- a/services/app-web/src/pages/StateSubmission/Documents/Documents.test.tsx +++ b/services/app-web/src/pages/StateSubmission/Documents/Documents.test.tsx @@ -1219,7 +1219,7 @@ describe('Documents', () => { { s3URL: 's3://bucketname/key/supporting-documents', name: 'supporting documents', - sha256: 'fakesha', + sha256: 'fakesha2', documentCategories: ['RATES_RELATED' as const], }, ], @@ -1261,7 +1261,7 @@ describe('Documents', () => { expect.objectContaining({ name: 'supporting documents', s3URL: expect.anything(), - sha256: undefined, + sha256: expect.anything(), documentCategories: ['RATES_RELATED'], }), expect.objectContaining({ diff --git a/services/app-web/src/pages/StateSubmission/SubmissionType/SubmissionType.test.tsx b/services/app-web/src/pages/StateSubmission/SubmissionType/SubmissionType.test.tsx index d858a96b9a..27b64d61e3 100644 --- a/services/app-web/src/pages/StateSubmission/SubmissionType/SubmissionType.test.tsx +++ b/services/app-web/src/pages/StateSubmission/SubmissionType/SubmissionType.test.tsx @@ -32,7 +32,7 @@ describe('SubmissionType', () => { const requiredLabels = await screen.findAllByText('Required') expect(requiredLabels).toHaveLength(6) - const optionalLabels = await screen.queryAllByText('Optional') + const optionalLabels = screen.queryAllByText('Optional') expect(optionalLabels).toHaveLength(0) }) diff --git a/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts b/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts index ae4394e2e0..d9c474fd8b 100644 --- a/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts @@ -154,7 +154,7 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ }, { s3URL: 's3://bucketname/1648490162641-lifeofgalileo.pdf/lifeofgalileo.pdf', - sha256: 'fakesha', + sha256: 'fakesha1', name: 'lifeofgalileo.pdf', documentCategories: ['CONTRACT'], }, @@ -164,13 +164,13 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ rateDocuments: [ { s3URL: 's3://bucketname/1648242665634-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', - sha256: 'fakesha', + sha256: 'fakesha2', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['RATES'], }, { s3URL: 's3://bucketname/1648242711421-Amerigroup Texas Inc copy.pdf/Amerigroup Texas Inc copy.pdf', - sha256: 'fakesha', + sha256: 'fakesha3', name: 'Amerigroup Texas Inc copy.pdf', documentCategories: ['RATES'], }, @@ -178,7 +178,7 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ supportingDocuments: [ { s3URL: 's3://bucketname/1648242873229-covid-ifc-2-flu-rsv-codes 5-5-2021.pdf/covid-ifc-2-flu-rsv-codes 5-5-2021.pdf', - sha256: 'fakesha', + sha256: 'fakesha5', name: 'covid-ifc-2-flu-rsv-codes 5-5-2021.pdf', documentCategories: ['RATES_RELATED'], }, @@ -190,7 +190,7 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ documents: [ { s3URL: 's3://bucketname/1648242711421-529-10-0020-00003_Superior_Health Plan, Inc.pdf/529-10-0020-00003_Superior_Health Plan, Inc.pdf', - sha256: 'fakesha', + sha256: 'fakesha3', name: '529-10-0020-00003_Superior_Health Plan, Inc.pdf', documentCategories: ['CONTRACT_RELATED'], }, @@ -206,13 +206,13 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ }, { s3URL: 's3://bucketname/1648242665634-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', - sha256: 'fakesha', + sha256: 'fakesha2', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['CONTRACT'], }, { s3URL: 's3://bucketname/1648242711421-Amerigroup Texas Inc copy.pdf/Amerigroup Texas Inc copy.pdf', - sha256: 'fakesha', + sha256: 'fakesha4', name: 'Amerigroup Texas Inc copy.pdf', documentCategories: ['CONTRACT'], }, @@ -222,13 +222,13 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ rateDocuments: [ { s3URL: 's3://bucketname/1648242711421-529-10-0020-00003_Superior_Health Plan, Inc.pdf/529-10-0020-00003_Superior_Health Plan, Inc.pdf', - sha256: 'fakesha', + sha256: 'fakesha3', name: '529-10-0020-00003_Superior_Health Plan, Inc.pdf', documentCategories: ['RATES'], }, { s3URL: 's3://bucketname/1648242873229-covid-ifc-2-flu-rsv-codes 5-5-2021.pdf/covid-ifc-2-flu-rsv-codes 5-5-2021.pdf', - sha256: 'fakesha', + sha256: 'fakesha5', name: 'covid-ifc-2-flu-rsv-codes 5-5-2021.pdf', documentCategories: ['RATES'], }, @@ -242,7 +242,7 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ supportingDocuments: [ { s3URL: 's3://bucketname/1648242711421-529-10-0020-00003_Superior_Health Plan, Inc.pdf/529-10-0020-00003_Superior_Health Plan, Inc.pdf', - sha256: 'fakesha', + sha256: 'fakesha3', name: '529-10-0020-00003_Superior_Health Plan, Inc.pdf', documentCategories: ['RATES_RELATED'], }, @@ -254,13 +254,13 @@ const mockSubmittedHealthPlanPackageWithRevision = ({ documents: [ { s3URL: 's3://bucketname/1648242665634-Amerigroup Texas, Inc.pdf/Amerigroup Texas, Inc.pdf', - sha256: 'fakesha', + sha256: 'fakesha2', name: 'Amerigroup Texas, Inc.pdf', documentCategories: ['CONTRACT_RELATED'], }, { s3URL: 's3://bucketname/1648242711421-Amerigroup Texas Inc copy.pdf/Amerigroup Texas Inc copy.pdf', - sha256: 'fakesha', + sha256: 'fakesha4', name: 'Amerigroup Texas Inc copy.pdf', documentCategories: ['CONTRACT_RELATED'], }, From 96f62b5b3a7633a2e51b3a9f352365eded4e7f22 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Fri, 29 Sep 2023 15:06:31 -0700 Subject: [PATCH 19/23] get all tests passing: --- .../postgres/contractAndRates/submitContract.ts | 10 ++++++++-- .../unlockHealthPlanPackage.test.ts | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index 832f1a89b5..fb38144e0c 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -71,9 +71,15 @@ async function submitContract( }, rateRevisions: { createMany: { - data: relatedRateRevs.map((rev) => ({ + data: relatedRateRevs.map((rev, idx) => ({ rateRevisionID: rev.id, - validAfter: currentDateTime, + // Since rates come out the other side ordered by validAfter, we need to order things on the way in that way. + validAfter: new Date( + currentDateTime.getTime() - + relatedRateRevs.length + + idx + + 1 + ), })), }, }, diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts index 2a0c8b1eb2..0359449102 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.test.ts @@ -315,7 +315,7 @@ describe.each(flagValueTestParameters)( { rateDateStart: new Date(), rateDateEnd: new Date(), - rateProgramIDs: ['5c10fe9f-bec9-416f-a20c-718b152ad633'], + rateProgramIDs: ['08d114c2-0c01-4a1a-b8ff-e2b79336672d'], rateType: 'NEW', rateDateCertified: new Date(), rateDocuments: [ @@ -376,6 +376,14 @@ describe.each(flagValueTestParameters)( ) const unlockedFormData = latestFormData(unlockedPKG) + const unlockedRateDocs = unlockedFormData.rateInfos.map( + (r) => r.rateDocuments[0].name + ) + expect(unlockedRateDocs).toEqual([ + 'rateDocument.pdf', + 'fake doc', + 'fake doc number two', + ]) // remove the first rate unlockedFormData.rateInfos = unlockedFormData.rateInfos.slice(1) @@ -426,7 +434,10 @@ describe.each(flagValueTestParameters)( base64ToDomain(r.node.formDataProto) ) - expect(formDatas).toHaveLength(6) // This probably doesn't make sense totally but is fine for now. + // right now the history is a bit weird + const expectedRevCount = flagValue ? 6 : 3 + + expect(formDatas).toHaveLength(expectedRevCount) // This probably doesn't make sense totally but is fine for now. // throw new Error('Not done with this test yet') }, 20000) From a4899b931e37d846808f732e2116a1557a8759c6 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Fri, 29 Sep 2023 15:19:21 -0700 Subject: [PATCH 20/23] address jason comments --- .../contractAndRates/prismaSubmittedContractHelpers.ts | 4 ++-- .../contractAndRates/prismaSubmittedRateHelpers.ts | 4 ++-- .../src/postgres/contractAndRates/submitContract.ts | 4 ++-- .../app-api/src/postgres/contractAndRates/submitRate.ts | 4 ++-- .../contractAndRates/updateDraftContractWithRates.ts | 8 -------- 5 files changed, 8 insertions(+), 16 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts index 5faf5d410d..686068753d 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts @@ -7,7 +7,7 @@ import { // Generated Types -const includeFirstSubmittedContractRev = { +const includeLatestSubmittedRateRev = { revisions: { where: { submitInfoID: { not: null }, @@ -56,6 +56,6 @@ type ContractTableFullPayload = Prisma.ContractTableGetPayload<{ type ContractRevisionTableWithRates = ContractTableFullPayload['revisions'][0] -export { includeFullContract, includeFirstSubmittedContractRev } +export { includeFullContract, includeLatestSubmittedRateRev } export type { ContractRevisionTableWithRates, ContractTableFullPayload } diff --git a/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts index a2a4be7a5b..74b8e47f58 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts @@ -5,7 +5,7 @@ import { includeRateFormData, } from './prismaSharedContractRateHelpers' -const includeFirstSubmittedRateRev = { +const includeLatestSubmittedRateRev = { revisions: { where: { submitInfoID: { not: null }, @@ -54,6 +54,6 @@ type RateTableFullPayload = Prisma.RateTableGetPayload<{ type RateRevisionTableWithContracts = RateTableFullPayload['revisions'][0] -export { includeFullRate, includeFirstSubmittedRateRev } +export { includeFullRate, includeLatestSubmittedRateRev } export type { RateTableFullPayload, RateRevisionTableWithContracts } diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index fb38144e0c..30348e5f79 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -3,7 +3,7 @@ import type { ContractType } from '../../domain-models/contractAndRates' import { findContractWithHistory } from './findContractWithHistory' import { NotFoundError } from '../storeError' import type { UpdateInfoType } from '../../domain-models' -import { includeFirstSubmittedRateRev } from './prismaSubmittedRateHelpers' +import { includeLatestSubmittedRateRev } from './prismaSubmittedRateHelpers' type SubmitContractArgsType = { contractID: string // revision ID @@ -31,7 +31,7 @@ async function submitContract( }, include: { draftRates: { - include: includeFirstSubmittedRateRev, + include: includeLatestSubmittedRateRev, }, }, }) diff --git a/services/app-api/src/postgres/contractAndRates/submitRate.ts b/services/app-api/src/postgres/contractAndRates/submitRate.ts index 881a0b7599..7f240f4025 100644 --- a/services/app-api/src/postgres/contractAndRates/submitRate.ts +++ b/services/app-api/src/postgres/contractAndRates/submitRate.ts @@ -2,7 +2,7 @@ import { findRateWithHistory } from './findRateWithHistory' import type { UpdateInfoType } from '../../domain-models' import type { PrismaClient } from '@prisma/client' import type { RateType } from '../../domain-models/contractAndRates' -import { includeFirstSubmittedContractRev } from './prismaSubmittedContractHelpers' +import { includeLatestSubmittedRateRev } from './prismaSubmittedContractHelpers' import { NotFoundError } from '../storeError' type SubmitRateArgsType = { @@ -49,7 +49,7 @@ async function submitRate( where: findWhere, include: { draftContracts: { - include: includeFirstSubmittedContractRev, + include: includeLatestSubmittedRateRev, }, }, }) diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts index e19474d771..a60e6eaff2 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts @@ -142,14 +142,6 @@ async function updateDraftContractWithRates( try { return await client.$transaction(async (tx) => { - const foundBeforeContract = await findContractWithHistory( - tx, - contractID - ) - - if (foundBeforeContract instanceof Error) { - return foundBeforeContract - } // Given all the Contracts associated with this draft, find the most recent submitted const currentRev = await tx.contractRevisionTable.findFirst({ where: { From 5794743f26194da38e48f07df393fe663d284808 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Mon, 2 Oct 2023 01:02:12 -0700 Subject: [PATCH 21/23] fix up submit parsing --- .../convertContractWithRatesToHPP.ts | 30 ++--- .../indexHealthPlanPackages.test.ts | 8 -- .../submitHealthPlanPackage.ts | 41 ++++++ .../proto/healthPlanFormDataProto/toDomain.ts | 118 ++++++++++-------- 4 files changed, 119 insertions(+), 78 deletions(-) diff --git a/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts b/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts index 6f82ce5b41..c253535984 100644 --- a/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts +++ b/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts @@ -15,10 +15,8 @@ import { toProtoBuffer, } from '../../../../app-web/src/common-code/proto/healthPlanFormDataProto' import type { ContractRevisionWithRatesType } from './revisionTypes' -import { - isSubmissionError, - parseAndSubmit, -} from '../../resolvers/healthPlanPackage/submitHealthPlanPackage' +import { parsePartialHPFD } from 'app-web/src/common-code/proto/healthPlanFormDataProto/toDomain' +import type { PartialHealthPlanFormData } from 'app-web/src/common-code/proto/healthPlanFormDataProto/toDomain' function convertContractWithRatesToUnlockedHPP( contract: ContractType @@ -86,7 +84,6 @@ function convertContractWithRatesRevtoHPPRev( return healthPlanRevisions } -// TODO: Clean up parameters into args and improve types to make things more strict function convertContractWithRatesToFormData( contractRev: ContractRevisionWithRatesType, contractID: string, @@ -157,9 +154,8 @@ function convertContractWithRatesToFormData( ) // since this data is coming out from the DB without validation, we start by making a draft. - const healthPlanFormData: HealthPlanFormDataType = { + const healthPlanFormData: PartialHealthPlanFormData = { id: contractID, // contract form data id is the contract ID. - status: 'DRAFT', createdAt: contractRev.createdAt, updatedAt: contractRev.updatedAt, stateCode: stateCode, @@ -230,22 +226,18 @@ function convertContractWithRatesToFormData( rateInfos, } + const status = contractRev.submitInfo ? 'SUBMITTED' : 'DRAFT' if (contractRev.submitInfo) { - const result = parseAndSubmit(healthPlanFormData) - if (isSubmissionError(result)) { - console.error( - 'Failed to parse contract data into submitted HPFD', - result - ) - return new Error( - 'The data did not parse correctly as a submitted health plan' - ) - } + healthPlanFormData.submittedAt = contractRev.submitInfo.updatedAt + } + + const formDataResult = parsePartialHPFD(status, healthPlanFormData) - return result + if (formDataResult instanceof Error) { + console.error('couldnt parse into valid form data', formDataResult) } - return healthPlanFormData + return formDataResult } export { diff --git a/services/app-api/src/resolvers/healthPlanPackage/indexHealthPlanPackages.test.ts b/services/app-api/src/resolvers/healthPlanPackage/indexHealthPlanPackages.test.ts index ae302aa1a8..a15114789f 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/indexHealthPlanPackages.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/indexHealthPlanPackages.test.ts @@ -466,14 +466,6 @@ describe('indexHealthPlanPackages test rates-db-refactor flag on only', () => { }) ) - // expect console.error to log contract that failed coverting - expect(errors).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'indexHealthPlanPackagesResolver failed', - error: expect.stringContaining(validParsedSubmittedContract.id), - }) - ) - // Expect our contract that passed checks expect(contracts).toEqual( expect.arrayContaining([ diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index 20b00b6728..888a5d7367 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -336,6 +336,47 @@ export function submitHealthPlanPackageResolver( message: maybeLocked.message, }) } + + // Since submit can change the form data, we have to save it again. + const updateResult = await store.updateDraftContractWithRates({ + contractID: input.pkgID, + formData: { + ...maybeLocked, + ...maybeLocked.contractAmendmentInfo?.modifiedProvisions, + managedCareEntities: maybeLocked.managedCareEntities, + stateContacts: maybeLocked.stateContacts, + supportingDocuments: maybeLocked.documents.map((doc) => { + return { + name: doc.name, + s3URL: doc.s3URL, + sha256: doc.sha256, + id: doc.id, + } + }), + contractDocuments: maybeLocked.contractDocuments.map( + (doc) => { + return { + name: doc.name, + s3URL: doc.s3URL, + sha256: doc.sha256, + id: doc.id, + } + } + ), + }, + }) + if (updateResult instanceof Error) { + const errMessage = `Failed to update submitted contract info with ID: ${contractRevisionID}; ${updateResult.message}` + logError('submitHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + // If there are rates, submit those first if (initialFormData.rateInfos.length > 0) { const ratePromises: Promise[] = [] diff --git a/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts b/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts index 1ee2d82a73..7afddfecdf 100644 --- a/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts +++ b/services/app-web/src/common-code/proto/healthPlanFormDataProto/toDomain.ts @@ -13,6 +13,7 @@ import { isLockedHealthPlanFormData, RateInfoType, generateRateName, + HealthPlanFormDataType, } from '../../healthPlanFormDataType' import { toLatestProtoVersion } from './toLatestVersion' import { findStatePrograms } from '../../healthPlanFormDataType/findStatePrograms' @@ -387,6 +388,62 @@ function parseRateInfos( // End Parsers +type PartialHealthPlanFormData = + RecursivePartial & + RecursivePartial + +function parsePartialHPFD( + status: string | undefined | null, + maybeUnlockedFormData: PartialHealthPlanFormData +): HealthPlanFormDataType | Error { + if (status === 'DRAFT') { + // cast so we can set status + const maybeDraft = + maybeUnlockedFormData as RecursivePartial + maybeDraft.status = 'DRAFT' + // This parse returns an actual UnlockedHealthPlanFormDataType, so all our partial & casting is put to rest + const parseResult = + unlockedHealthPlanFormDataZodSchema.safeParse(maybeDraft) + if (parseResult.success === false) { + return parseResult.error + } + /* We need a one-off modification here because some older submissions don't have a populated + rateCertificationName field. If it's missing, we'll generate it and add it to the form data. + We do it for locked or unlocked submissions. */ + return updateRateCertificationNames( + parseResult.data as UnlockedHealthPlanFormDataType + ) + } else if (status === 'SUBMITTED') { + const maybeLockedFormData = + maybeUnlockedFormData as RecursivePartial + maybeLockedFormData.status = 'SUBMITTED' + + const parseResult = + lockedHealthPlanFormDataZodSchema.safeParse(maybeLockedFormData) + if (parseResult.success === false) { + console.warn( + 'ERROR: attempting to parse state submission proto failed.' + ) + return new Error( + 'ERROR: attempting to parse state submission proto failed' + ) + } + if (isLockedHealthPlanFormData(maybeLockedFormData)) { + /* We need a one-off modification here because some older submissions don't have a populated + rateCertificationName field. If it's missing, we'll generate it and add it to the form data. + We do it for locked or unlocked submissions. */ + return updateRateCertificationNames(maybeLockedFormData) + } else { + return new Error( + 'ERROR: attempting to parse state submission proto failed' + ) + } + } + + // unknown or missing status means we've got a parse error. + return new Error('Unknown or missing status on this proto. Cannot decode.') +} + const toDomain = ( buff: Uint8Array ): UnlockedHealthPlanFormDataType | LockedHealthPlanFormDataType | Error => { @@ -427,8 +484,7 @@ const toDomain = ( // Since everything in proto-land is optional, we construct a RecursivePartial version of our domain models // and - const maybeUnlockedFormData: RecursivePartial & - RecursivePartial = { + const maybeUnlockedFormData: PartialHealthPlanFormData = { id: id ?? undefined, createdAt: protoDateToDomain(createdAt), updatedAt: protoTimestampToDomain(updatedAt), @@ -491,57 +547,17 @@ const toDomain = ( // Now that we've gotten things into our combined draft & state domain format. // we confirm that all the required fields are present to turn this into an UnlockedHealthPlanFormDataType or a LockedHealthPlanFormDataType - if (status === 'DRAFT') { - // cast so we can set status - const maybeDraft = - maybeUnlockedFormData as RecursivePartial - maybeDraft.status = 'DRAFT' - // This parse returns an actual UnlockedHealthPlanFormDataType, so all our partial & casting is put to rest - const parseResult = - unlockedHealthPlanFormDataZodSchema.safeParse(maybeDraft) - if (parseResult.success === false) { - return parseResult.error - } - /* We need a one-off modification here because some older submissions don't have a populated - rateCertificationName field. If it's missing, we'll generate it and add it to the form data. - We do it for locked or unlocked submissions. */ - return updateRateCertificationNames( - parseResult.data as UnlockedHealthPlanFormDataType - ) - } else if (status === 'SUBMITTED') { - const maybeLockedFormData = - maybeUnlockedFormData as RecursivePartial - maybeLockedFormData.status = 'SUBMITTED' - const parseResult = - lockedHealthPlanFormDataZodSchema.safeParse(maybeLockedFormData) - if (parseResult.success === false) { - console.warn( - 'ERROR: attempting to parse state submission proto failed.' - ) - return new Error( - 'ERROR: attempting to parse state submission proto failed' - ) - } - if (isLockedHealthPlanFormData(maybeLockedFormData)) { - /* We need a one-off modification here because some older submissions don't have a populated - rateCertificationName field. If it's missing, we'll generate it and add it to the form data. - We do it for locked or unlocked submissions. */ - return updateRateCertificationNames(maybeLockedFormData) - } else { - console.warn( - 'ERROR: attempting to parse state submission proto failed.', - id - ) - return new Error( - 'ERROR: attempting to parse state submission proto failed' - ) - } + const formDataResult = parsePartialHPFD(status, maybeUnlockedFormData) + if (formDataResult instanceof Error) { + console.warn( + 'ERROR: attempting to parse state submission proto failed.', + id + ) } - // unknown or missing status means we've got a parse error. - console.warn('ERROR: Unknown or missing status on this proto.', id, status) - return new Error('Unknown or missing status on this proto. Cannot decode.') + return formDataResult } -export { toDomain, decodeOrError } +export { toDomain, decodeOrError, parsePartialHPFD } +export type { PartialHealthPlanFormData } From c4e7c3ea5ee9ec90eb1a7e85f0744f9f9e7cf5e9 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Mon, 2 Oct 2023 01:50:58 -0700 Subject: [PATCH 22/23] im adding the bad imports --- .../contractAndRates/convertContractWithRatesToHPP.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts b/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts index c253535984..0ad2968a29 100644 --- a/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts +++ b/services/app-api/src/domain-models/contractAndRates/convertContractWithRatesToHPP.ts @@ -15,8 +15,8 @@ import { toProtoBuffer, } from '../../../../app-web/src/common-code/proto/healthPlanFormDataProto' import type { ContractRevisionWithRatesType } from './revisionTypes' -import { parsePartialHPFD } from 'app-web/src/common-code/proto/healthPlanFormDataProto/toDomain' -import type { PartialHealthPlanFormData } from 'app-web/src/common-code/proto/healthPlanFormDataProto/toDomain' +import { parsePartialHPFD } from '../../../../app-web/src/common-code/proto/healthPlanFormDataProto/toDomain' +import type { PartialHealthPlanFormData } from '../../../../app-web/src/common-code/proto/healthPlanFormDataProto/toDomain' function convertContractWithRatesToUnlockedHPP( contract: ContractType From b6c4b35352eade99f198c6f66171ffaf681921b2 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Mon, 2 Oct 2023 10:25:04 -0700 Subject: [PATCH 23/23] address comments --- .../contractAndRates/insertContract.ts | 21 +++++++++++-- .../postgres/contractAndRates/insertRate.ts | 28 ++++++++++++++--- .../updateDraftContractWithRates.ts | 20 +----------- .../src/postgres/prismaDomainAdaptors.ts | 20 ++++++++++++ .../submitHealthPlanPackage.ts | 31 ++++++------------- 5 files changed, 73 insertions(+), 47 deletions(-) create mode 100644 services/app-api/src/postgres/prismaDomainAdaptors.ts diff --git a/services/app-api/src/postgres/contractAndRates/insertContract.ts b/services/app-api/src/postgres/contractAndRates/insertContract.ts index c59fb8b14d..13ba1e1d18 100644 --- a/services/app-api/src/postgres/contractAndRates/insertContract.ts +++ b/services/app-api/src/postgres/contractAndRates/insertContract.ts @@ -80,13 +80,28 @@ async function insertDraftContract( contractType: contractType, contractExecutionStatus, contractDocuments: { - create: contractDocuments, + create: + contractDocuments && + contractDocuments.map((d, idx) => ({ + position: idx, + ...d, + })), }, supportingDocuments: { - create: supportingDocuments, + create: + supportingDocuments && + supportingDocuments.map((d, idx) => ({ + position: idx, + ...d, + })), }, stateContacts: { - create: stateContacts, + create: + stateContacts && + stateContacts.map((c, idx) => ({ + position: idx, + ...c, + })), }, contractDateStart, contractDateEnd, diff --git a/services/app-api/src/postgres/contractAndRates/insertRate.ts b/services/app-api/src/postgres/contractAndRates/insertRate.ts index 32f902e5c7..69a874cb5f 100644 --- a/services/app-api/src/postgres/contractAndRates/insertRate.ts +++ b/services/app-api/src/postgres/contractAndRates/insertRate.ts @@ -54,10 +54,20 @@ async function insertDraftRate( rateType, rateCapitationType: rateCapitationType, rateDocuments: { - create: rateDocuments, + create: + rateDocuments && + rateDocuments.map((d, idx) => ({ + position: idx, + ...d, + })), }, supportingDocuments: { - create: supportingDocuments, + create: + supportingDocuments && + supportingDocuments.map((d, idx) => ({ + position: idx, + ...d, + })), }, rateDateStart, rateDateEnd, @@ -67,10 +77,20 @@ async function insertDraftRate( rateProgramIDs, rateCertificationName, certifyingActuaryContacts: { - create: certifyingActuaryContacts, + create: + certifyingActuaryContacts && + certifyingActuaryContacts.map((c, idx) => ({ + position: idx, + ...c, + })), }, addtlActuaryContacts: { - create: addtlActuaryContacts, + create: + addtlActuaryContacts && + addtlActuaryContacts.map((c, idx) => ({ + position: idx, + ...c, + })), }, actuaryCommunicationPreference, }, diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts index a60e6eaff2..a21a31b7a2 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts @@ -12,25 +12,7 @@ import { includeDraftRates } from './prismaDraftContractHelpers' import { rateRevisionToDomainModel } from './prismaSharedContractRateHelpers' import type { RateFormEditable } from './updateDraftRate' import { isEqualData } from '../../resolvers/healthPlanPackage/contractAndRates/resolverHelpers' - -// since prisma needs nulls to indicate "remove this field" instead of "ignore this field" -// this function translates undefineds into nulls -function nullify(field: T | undefined): T | null { - if (field === undefined) { - return null - } - - return field -} - -// since prisma needs nulls to indicate "remove this field" instead of "ignore this field" -// this function translates undefineds into empty arrays -function emptify(field: T[] | undefined): T[] { - if (field === undefined) { - return [] - } - return field -} +import { emptify, nullify } from '../prismaDomainAdaptors' type ContractFormEditable = Partial diff --git a/services/app-api/src/postgres/prismaDomainAdaptors.ts b/services/app-api/src/postgres/prismaDomainAdaptors.ts new file mode 100644 index 0000000000..b276f41cb2 --- /dev/null +++ b/services/app-api/src/postgres/prismaDomainAdaptors.ts @@ -0,0 +1,20 @@ +// since prisma needs nulls to indicate "remove this field" instead of "ignore this field" +// this function translates undefineds into nulls +function nullify(field: T | undefined): T | null { + if (field === undefined) { + return null + } + + return field +} + +// since prisma needs nulls to indicate "remove this field" instead of "ignore this field" +// this function translates undefineds into empty arrays +function emptify(field: T[] | undefined): T[] { + if (field === undefined) { + return [] + } + return field +} + +export { nullify, emptify } diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index 888a5d7367..a7a896a53f 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -46,6 +46,7 @@ import type { PackageStatusType, RateType, } from '../../domain-models/contractAndRates' +import type { RateFormEditable } from '../../postgres/contractAndRates/updateDraftRate' export const SubmissionErrorCodes = ['INCOMPLETE', 'INVALID'] as const type SubmissionErrorCode = (typeof SubmissionErrorCodes)[number] // iterable union type @@ -305,26 +306,6 @@ export function submitHealthPlanPackageResolver( initialFormData = conversionResult contractRevisionID = contractWithHistory.revisions[0].id - // If we are submitting a CONTRACT ONLY but it still has rates associated with it, we need to remove those draftRates now - if ( - initialFormData.submissionType === 'CONTRACT_ONLY' && - initialFormData.rateInfos.length > 0 - ) { - const rateRemovalResult = - await store.updateDraftContractWithRates({ - contractID: contractWithHistory.id, - formData: contractWithHistory.draftRevision.formData, - rateFormDatas: [], - }) - if (rateRemovalResult instanceof Error) { - const errMessage = - 'Failed to remove draft rates from a CONTRACT ONLY submission: ' + - rateRemovalResult.message - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new Error(errMessage) - } - } // Final clean + check of data before submit - parse to state submission const maybeLocked = parseAndSubmit(initialFormData) @@ -338,6 +319,13 @@ export function submitHealthPlanPackageResolver( } // Since submit can change the form data, we have to save it again. + // if the rates were removed, we remove them. + let removeRateInfos: RateFormEditable[] | undefined = undefined + if (maybeLocked.rateInfos.length === 0) { + // undefined means ignore rates in updaterDraftContractWithRates, empty array means empty them. + removeRateInfos = [] + } + const updateResult = await store.updateDraftContractWithRates({ contractID: input.pkgID, formData: { @@ -364,6 +352,7 @@ export function submitHealthPlanPackageResolver( } ), }, + rateFormDatas: removeRateInfos, }) if (updateResult instanceof Error) { const errMessage = `Failed to update submitted contract info with ID: ${contractRevisionID}; ${updateResult.message}` @@ -378,7 +367,7 @@ export function submitHealthPlanPackageResolver( } // If there are rates, submit those first - if (initialFormData.rateInfos.length > 0) { + if (contractWithHistory.revisions[0].rateRevisions.length > 0) { const ratePromises: Promise[] = [] contractWithHistory.revisions[0].rateRevisions.forEach( (rateRev) => {