From f8fc4e25c683dbd51d50180862d5a683f34cd651 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Mon, 1 Apr 2024 11:48:11 -0700 Subject: [PATCH 01/49] initial commit of resolvers working --- .../src/resolvers/configureResolvers.ts | 4 + .../resolvers/contract/contractResolver.ts | 21 +- .../contract/contractRevisionResolver.ts | 27 ++ .../resolvers/contract/fetchContract.test.ts | 23 ++ .../resolvers/contract/submitContract.test.ts | 111 +++++++ .../src/resolvers/contract/submitContract.ts | 24 ++ services/app-graphql/codegen.yml | 1 + .../src/mutations/submitContract.graphql | 300 ++++++++++++++++++ services/app-graphql/src/schema.graphql | 43 +++ 9 files changed, 534 insertions(+), 20 deletions(-) create mode 100644 services/app-api/src/resolvers/contract/contractRevisionResolver.ts create mode 100644 services/app-api/src/resolvers/contract/submitContract.test.ts create mode 100644 services/app-api/src/resolvers/contract/submitContract.ts create mode 100644 services/app-graphql/src/mutations/submitContract.graphql diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 11a5e5ad72..bef54084b8 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -36,7 +36,9 @@ import { unlockRate } from './rate/unlockRate' import { submitRate } from './rate/submitRate' import { updateDraftContractRates } from './contract/updateDraftContractRates' import { contractResolver } from './contract/contractResolver' +import { contractRevisionResolver } from './contract/contractRevisionResolver' import { fetchContractResolver } from './contract/fetchContract' +import { submitContract } from './contract/submitContract' export function configureResolvers( store: Store, @@ -76,6 +78,7 @@ export function configureResolvers( emailParameterStore, launchDarkly ), + submitContract: submitContract(store), unlockHealthPlanPackage: unlockHealthPlanPackageResolver( store, emailer, @@ -121,6 +124,7 @@ export function configureResolvers( HealthPlanPackage: healthPlanPackageResolver(store), Rate: rateResolver, Contract: contractResolver(store), + ContractRevision: contractRevisionResolver(store), } return resolvers diff --git a/services/app-api/src/resolvers/contract/contractResolver.ts b/services/app-api/src/resolvers/contract/contractResolver.ts index 1077e30ac3..76b3072798 100644 --- a/services/app-api/src/resolvers/contract/contractResolver.ts +++ b/services/app-api/src/resolvers/contract/contractResolver.ts @@ -4,11 +4,10 @@ import { logError } from '../../logger' import type { Store } from '../../postgres' import { GraphQLError } from 'graphql' import { setErrorAttributesOnActiveSpan } from '../attributeHelper' -import { packageName } from '../../../../app-web/src/common-code/healthPlanFormDataType' export function contractResolver(store: Store): Resolvers['Contract'] { return { - initiallySubmittedAt(parent) { + initiallySubmittedAt(_parent) { // we're only working on drafts for now, this will need to change to // look at the revisions when we expand return null @@ -32,24 +31,6 @@ export function contractResolver(store: Store): Resolvers['Contract'] { return state }, - draftRevision(parent) { - const programsForContractState = statePrograms.states - .find((state) => state.code === parent.stateCode) - ?.programs.filter((program) => program !== undefined) - const contractName = packageName( - parent.stateCode, - parent.stateNumber, - parent.draftRevision?.formData.programIDs ?? [], - programsForContractState ?? [] - ) - - return ( - { - ...parent.draftRevision, - contractName, - } || {} - ) - }, draftRates: async (parent, _args, context) => { const { span } = context const rateDataArray = parent.draftRevision?.rateRevisions || [] diff --git a/services/app-api/src/resolvers/contract/contractRevisionResolver.ts b/services/app-api/src/resolvers/contract/contractRevisionResolver.ts new file mode 100644 index 0000000000..7464d69023 --- /dev/null +++ b/services/app-api/src/resolvers/contract/contractRevisionResolver.ts @@ -0,0 +1,27 @@ +import { packageName } from 'app-web/src/common-code/healthPlanFormDataType' +import type { ContractRevisionType } from '../../domain-models' +import type { Resolvers } from '../../gen/gqlServer' +import type { Store } from '../../postgres' + +export function contractRevisionResolver( + store: Store +): Resolvers['ContractRevision'] { + return { + contractName(parent: ContractRevisionType): string { + const stateCode = parent.contract.stateCode + const programsForContractState = store.findStatePrograms(stateCode) + if (programsForContractState instanceof Error) { + throw programsForContractState + } + + const contractName = packageName( + stateCode, + parent.contract.stateNumber, + parent.formData.programIDs, + programsForContractState ?? [] + ) + + return contractName + }, + } +} diff --git a/services/app-api/src/resolvers/contract/fetchContract.test.ts b/services/app-api/src/resolvers/contract/fetchContract.test.ts index 04499a70d5..b57058424b 100644 --- a/services/app-api/src/resolvers/contract/fetchContract.test.ts +++ b/services/app-api/src/resolvers/contract/fetchContract.test.ts @@ -34,6 +34,29 @@ describe('fetchContract', () => { expect(draftRate[0].stateCode).toBe('FL') }) + it('gets the right contract name', async () => { + const stateServer = await constructTestPostgresServer() + + const stateSubmission = + await createAndUpdateTestHealthPlanPackage(stateServer) + + const fetchDraftContractResult = await stateServer.executeOperation({ + query: FETCH_CONTRACT, + variables: { + input: { + contractID: stateSubmission.id, + }, + }, + }) + + expect(fetchDraftContractResult.errors).toBeUndefined() + + const draftContract = + fetchDraftContractResult.data?.fetchContract.contract.draftRevision + + expect(draftContract.contractName).toMatch(/MCR-FL-\d{4}-MMA/) + }) + it('errors if the wrong state user calls it', async () => { const stateServerFL = await constructTestPostgresServer() diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts new file mode 100644 index 0000000000..fb2f8269dc --- /dev/null +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -0,0 +1,111 @@ +import { + constructTestPostgresServer, + createTestHealthPlanPackage, +} from '../../testHelpers/gqlHelpers' +import UPDATE_DRAFT_CONTRACT_RATES from '../../../../app-graphql/src/mutations/updateDraftContractRates.graphql' +import SUBMIT_CONTRACT from '../../../../app-graphql/src/mutations/submitContract.graphql' + +describe('submitContract', () => { + it('submits a contract', async () => { + const stateServer = await constructTestPostgresServer() + + const draft = await createTestHealthPlanPackage(stateServer) + + const result = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: { + contractID: draft.id, + updatedRates: [ + { + type: 'CREATE', + formData: { + rateType: 'AMENDMENT', + rateCapitationType: 'RATE_CELL', + rateDateStart: '2024-01-01', + rateDateEnd: '2025-01-01', + amendmentEffectiveDateStart: '2024-02-01', + amendmentEffectiveDateEnd: '2025-02-01', + rateProgramIDs: ['foo'], + + rateDocuments: [ + { + s3URL: 'foo://bar', + name: 'ratedoc1.doc', + sha256: 'foobar', + }, + ], + supportingDocuments: [ + { + s3URL: 'foo://bar1', + name: 'ratesupdoc1.doc', + sha256: 'foobar1', + }, + { + s3URL: 'foo://bar2', + name: 'ratesupdoc2.doc', + sha256: 'foobar2', + }, + ], + certifyingActuaryContacts: [ + { + name: 'Foo Person', + titleRole: 'Bar Job', + email: 'foo@example.com', + actuarialFirm: 'GUIDEHOUSE', + }, + ], + addtlActuaryContacts: [ + { + name: 'Bar Person', + titleRole: 'Baz Job', + email: 'bar@example.com', + actuarialFirm: 'OTHER', + actuarialFirmOther: 'Some Firm', + }, + ], + actuaryCommunicationPreference: + 'OACT_TO_ACTUARY', + packagesWithSharedRateCerts: [], + }, + }, + ], + }, + }, + }) + + expect(result.errors).toBeUndefined() + if (!result.data) { + throw new Error('No data returned') + } + + const draftRates = + result.data.updateDraftContractRates.contract.draftRates + + expect(draftRates).toHaveLength(1) + + // SUBMIT + const submitResult = await stateServer.executeOperation({ + query: SUBMIT_CONTRACT, + variables: { + input: { + contractID: draft.id, + submittedReason: 'FIRST POST', + }, + }, + }) + + expect(submitResult.errors).toBeUndefined() + if (!submitResult.data) { + throw new Error('no data') + } + + const contract = submitResult.data.submitContract.contract + + expect(contract.draftContact).toBeUndefined() + + expect(contract.packageSubmissionHistory).toHaveLength(1) + + throw new Error('INCOMEONWE') + }) +}) diff --git a/services/app-api/src/resolvers/contract/submitContract.ts b/services/app-api/src/resolvers/contract/submitContract.ts new file mode 100644 index 0000000000..f89a58fbb3 --- /dev/null +++ b/services/app-api/src/resolvers/contract/submitContract.ts @@ -0,0 +1,24 @@ +import type { MutationResolvers } from '../../gen/gqlServer' +import type { Store } from '../../postgres' + +export function submitContract( + store: Store +): MutationResolvers['submitContract'] { + return async (_parent, { input }, context) => { + const realSubmitReason = input.submittedReason || 'Initial Submit' + + const contract = await store.submitContract({ + contractID: input.contractID, + submittedReason: realSubmitReason, + submittedByUserID: context.user.id, + }) + + if (contract instanceof Error) { + throw contract + } + + return { + contract, + } + } +} diff --git a/services/app-graphql/codegen.yml b/services/app-graphql/codegen.yml index b388c3961a..0460629aba 100644 --- a/services/app-graphql/codegen.yml +++ b/services/app-graphql/codegen.yml @@ -23,6 +23,7 @@ generates: Rate: ../domain-models#RateType RateRevision: ../domain-models#RateRevisionType Contract: ../domain-models#ContractType as ContractDomainType + ContractRevision: ../domain-models#ContractRevisionType ../app-web/src/gen/gqlClient.tsx: documents: - src/queries/*.graphql diff --git a/services/app-graphql/src/mutations/submitContract.graphql b/services/app-graphql/src/mutations/submitContract.graphql new file mode 100644 index 0000000000..6335083744 --- /dev/null +++ b/services/app-graphql/src/mutations/submitContract.graphql @@ -0,0 +1,300 @@ +mutation submitContract($input: SubmitContractInput!) { + submitContract(input: $input) { + contract { + ...contractFields + + draftRevision { + ...contractRevisionFragment + } + + draftRates { + ...rateFields + + draftRevision { + ...rateRevisionFragmentForFetchContract + } + + revisions { + ...rateRevisionFragmentForFetchContract + } + } + + packageSubmissions { + ...packageSubmissionsFragment + } + } + } +} + + +fragment contractFields on Contract { + id + status + createdAt + updatedAt + initiallySubmittedAt + stateCode + state { + code + name + programs { + id + name + fullName + } + } + + stateNumber +} + +fragment rateFields on Rate { + id + createdAt + updatedAt + stateCode + stateNumber + state { + code + name + programs { + id + name + fullName + } + } + status + initiallySubmittedAt +} + +fragment rateRevisionFragmentForFetchContract on RateRevision { + id + createdAt + updatedAt + unlockInfo { + updatedAt + updatedBy + updatedReason + } + submitInfo { + updatedAt + updatedBy + updatedReason + } + formData { + rateType + rateCapitationType + rateDocuments { + name + s3URL + sha256 + } + supportingDocuments { + name + s3URL + sha256 + } + rateDateStart + rateDateEnd + rateDateCertified + amendmentEffectiveDateStart + amendmentEffectiveDateEnd + rateProgramIDs + rateCertificationName + certifyingActuaryContacts { + id + name + titleRole + email + actuarialFirm + actuarialFirmOther + } + addtlActuaryContacts { + id + name + titleRole + email + actuarialFirm + actuarialFirmOther + } + actuaryCommunicationPreference + packagesWithSharedRateCerts { + packageName + packageId + packageStatus + } + } + contractRevisions { + id + contract { + id + stateCode + stateNumber + } + createdAt + updatedAt + submitInfo { + updatedAt + updatedBy + updatedReason + } + unlockInfo { + updatedAt + updatedBy + updatedReason + } + formData { + programIDs + populationCovered + submissionType + riskBasedContract + submissionDescription + stateContacts { + name + titleRole + email + } + supportingDocuments { + name + s3URL + sha256 + } + contractType + contractExecutionStatus + contractDocuments { + name + s3URL + sha256 + } + contractDateStart + contractDateEnd + managedCareEntities + federalAuthorities + inLieuServicesAndSettings + modifiedBenefitsProvided + modifiedGeoAreaServed + modifiedMedicaidBeneficiaries + modifiedRiskSharingStrategy + modifiedIncentiveArrangements + modifiedWitholdAgreements + modifiedStateDirectedPayments + modifiedPassThroughPayments + modifiedPaymentsForMentalDiseaseInstitutions + modifiedMedicalLossRatioStandards + modifiedOtherFinancialPaymentIncentive + modifiedEnrollmentProcess + modifiedGrevienceAndAppeal + modifiedNetworkAdequacyStandards + modifiedLengthOfContract + modifiedNonRiskPaymentArrangements + } + } +} + +fragment contractFormDataFragment on ContractFormData { + programIDs + + populationCovered + submissionType + + riskBasedContract + submissionDescription + + stateContacts { + name + titleRole + email + } + + supportingDocuments { + name + s3URL + sha256 + } + + contractType + contractExecutionStatus + contractDocuments { + name + s3URL + sha256 + } + + contractDateStart + contractDateEnd + managedCareEntities + federalAuthorities + inLieuServicesAndSettings + modifiedBenefitsProvided + modifiedGeoAreaServed + modifiedMedicaidBeneficiaries + modifiedRiskSharingStrategy + modifiedIncentiveArrangements + modifiedWitholdAgreements + modifiedStateDirectedPayments + modifiedPassThroughPayments + modifiedPaymentsForMentalDiseaseInstitutions + modifiedMedicaidBeneficiaries + modifiedMedicalLossRatioStandards + modifiedOtherFinancialPaymentIncentive + modifiedEnrollmentProcess + modifiedGrevienceAndAppeal + modifiedNetworkAdequacyStandards + modifiedLengthOfContract + modifiedNonRiskPaymentArrangements +} + +fragment contractRevisionFragment on ContractRevision { + id + createdAt + updatedAt + contractName + + submitInfo { + updatedAt + updatedBy + updatedReason + } + + unlockInfo { + updatedAt + updatedBy + updatedReason + } + + formData { + ...contractFormDataFragment + } +} + +fragment packageSubmissionsFragment on ContractPackageSubmission { + cause + submitInfo { + ...updateInformationFields + } + + submittedRevisions { + ...submittableRevisionsFields + } + + contractRevision { + ...contractRevisionFragment + } + rateRevisions { + ...rateRevisionFragmentForFetchContract + } +} + +fragment submittableRevisionsFields on SubmittableRevision { + ... on ContractRevision { + ...contractRevisionFragment + } + ... on RateRevision { + ...rateRevisionFragmentForFetchContract + } +} + +fragment updateInformationFields on UpdateInformation { + updatedAt + updatedBy + updatedReason +} diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index 1d177e6377..eba7886b7e 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -348,6 +348,39 @@ type Mutation { - Attempted to unlock a rate in the DRAFT or UNLOCKED state """ submitRate(input: SubmitRateInput!): SubmitRatePayload! + + """ + submitContract submits the given package for review by CMS. + + This can only be called by a StateUser from the state the package is for. + The package must be either in DRAFT or UNLOCKED state to be submitted + On resubmit the `submittedReason` field must be filled out. + The submission must be complete for this mutation to succeed. All required fields + in the healthPlanFormData must be filled out correctly. + Email notifications will be sent to all the relevant parties + + Errors: + - ForbiddenError: + - A CMSUser called this + - A state user from a different state called this. + - UserInputError + - A package cannot be found with the given `pkgID` + - The healthPlanFormData does not have all required field filled out + - INTERNAL_SERVER_ERROR + - DB_ERROR + - Postgres returns error when attempting to find a package + - Postgres returns error when attempting to update a package + - Attempt to find state programs from json file returns an error + - INVALID_PACKAGE_STATUS + - Attempted to submit a package in the SUBMITTED or RESUBMITTED state + - PROTO_DECODE_ERROR + - Failed to decode draft proto + - EMAIL_ERROR + - Sending state or CMS email failed. + """ + submitContract( + input: SubmitContractInput! + ): SubmitContractPayload! } input CreateHealthPlanPackageInput { @@ -491,6 +524,16 @@ type SubmitHealthPlanPackagePayload { pkg: HealthPlanPackage! } +input SubmitContractInput { + contractID: ID! + "User given reason this package was re-submitted. Left blank on initial submit." + submittedReason: String +} + +type SubmitContractPayload { + contract: Contract! +} + input UnlockHealthPlanPackageInput { pkgID: ID! "User given reason this package was unlocked" From b35508413578ce879a92b6f1a0e331e71d8118a6 Mon Sep 17 00:00:00 2001 From: Mojo Talantikite Date: Mon, 1 Apr 2024 16:19:09 -0400 Subject: [PATCH 02/49] submitContract can only be called by state user --- .../resolvers/contract/submitContract.test.ts | 25 +++++++++++++++++++ .../src/resolvers/contract/submitContract.ts | 17 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts index fb2f8269dc..901232aae5 100644 --- a/services/app-api/src/resolvers/contract/submitContract.test.ts +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -4,6 +4,8 @@ import { } from '../../testHelpers/gqlHelpers' import UPDATE_DRAFT_CONTRACT_RATES from '../../../../app-graphql/src/mutations/updateDraftContractRates.graphql' import SUBMIT_CONTRACT from '../../../../app-graphql/src/mutations/submitContract.graphql' +import { testCMSUser } from '../../testHelpers/userHelpers' +import type { SubmitContractInput } from '../../gen/gqlServer' describe('submitContract', () => { it('submits a contract', async () => { @@ -108,4 +110,27 @@ describe('submitContract', () => { throw new Error('INCOMEONWE') }) + + it('returns an error if a CMS user attempts to call submitContract', async () => { + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + }) + + const input: SubmitContractInput = { + contractID: 'fake-id-12345', + submittedReason: 'Test cms user calling state user func', + } + + const res = await cmsServer.executeOperation({ + query: SUBMIT_CONTRACT, + variables: { input }, + }) + + expect(res.errors).toBeDefined() + expect(res.errors && res.errors[0].message).toBe( + 'user not authorized to create state data' + ) + }) }) diff --git a/services/app-api/src/resolvers/contract/submitContract.ts b/services/app-api/src/resolvers/contract/submitContract.ts index f89a58fbb3..bb573b500a 100644 --- a/services/app-api/src/resolvers/contract/submitContract.ts +++ b/services/app-api/src/resolvers/contract/submitContract.ts @@ -1,10 +1,27 @@ +import { ForbiddenError } from 'apollo-server-core' +import { isStateUser } from '../../domain-models' import type { MutationResolvers } from '../../gen/gqlServer' import type { Store } from '../../postgres' +import { + setErrorAttributesOnActiveSpan, + setResolverDetailsOnActiveSpan, +} from '../attributeHelper' export function submitContract( store: Store ): MutationResolvers['submitContract'] { return async (_parent, { input }, context) => { + const { user, span } = context + setResolverDetailsOnActiveSpan('submitContract', user, span) + + // This resolver is only callable by state users + if (!isStateUser(user)) { + const errMsg = + 'submitContract: user not authorized to create state data' + setErrorAttributesOnActiveSpan(errMsg, span) + throw new ForbiddenError('user not authorized to create state data') + } + const realSubmitReason = input.submittedReason || 'Initial Submit' const contract = await store.submitContract({ From ce369d7742dd38d245c46218c29760ced0b80694 Mon Sep 17 00:00:00 2001 From: pearl-truss Date: Tue, 2 Apr 2024 11:59:38 -0400 Subject: [PATCH 03/49] create submissionsummaryv2 --- services/app-web/src/pages/App/AppRoutes.tsx | 15 +- .../V2/SubmissionSummaryV2.tsx | 259 ++++++++++++++++++ 2 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 services/app-web/src/pages/SubmissionSummary/V2/SubmissionSummaryV2.tsx diff --git a/services/app-web/src/pages/App/AppRoutes.tsx b/services/app-web/src/pages/App/AppRoutes.tsx index 4c88255134..4c4325b31c 100644 --- a/services/app-web/src/pages/App/AppRoutes.tsx +++ b/services/app-web/src/pages/App/AppRoutes.tsx @@ -25,6 +25,7 @@ import { Landing } from '../Landing/Landing' import { MccrsId } from '../MccrsId/MccrsId' import { NewStateSubmissionForm, StateSubmissionForm } from '../StateSubmission' import { SubmissionSummary } from '../SubmissionSummary' +import { SubmissionSummaryV2 } from '../SubmissionSummary/V2/SubmissionSummaryV2' import { SubmissionRevisionSummary } from '../SubmissionRevisionSummary' import { useScrollToPageTop } from '../../hooks/useScrollToPageTop' import { featureFlags } from '../../common-code/featureFlags' @@ -81,6 +82,10 @@ const StateUserRoutes = ({ featureFlags.RATE_EDIT_UNLOCK.flag, featureFlags.RATE_EDIT_UNLOCK.defaultValue ) + const useLinkedRates = ldClient?.variation( + featureFlags.LINK_RATES.flag, + featureFlags.LINK_RATES.defaultValue + ) return ( @@ -137,8 +142,14 @@ const StateUserRoutes = ({ )} } - /> + element={ + useLinkedRates ? ( + + ) : ( + + ) + } + /> } diff --git a/services/app-web/src/pages/SubmissionSummary/V2/SubmissionSummaryV2.tsx b/services/app-web/src/pages/SubmissionSummary/V2/SubmissionSummaryV2.tsx new file mode 100644 index 0000000000..871bd6ae34 --- /dev/null +++ b/services/app-web/src/pages/SubmissionSummary/V2/SubmissionSummaryV2.tsx @@ -0,0 +1,259 @@ +import { + GridContainer, + Icon, + Link, + ModalRef, + ModalToggleButton, +} from '@trussworks/react-uswds' +import React, { useEffect, useRef, useState } from 'react' +import { NavLink, useOutletContext } from 'react-router-dom' +import { packageName } from '../../../common-code/healthPlanFormDataType' +import { + ContractDetailsSummarySection, +} from '../../../components/SubmissionSummarySection' +import { ContactsSummarySection } from '../../StateSubmission/ReviewSubmit/V2/ReviewSubmit/ContactsSummarySectionV2' +import { RateDetailsSummarySectionV2 } from '../../StateSubmission/ReviewSubmit/V2/ReviewSubmit/RateDetailsSummarySectionV2' +import { SubmissionTypeSummarySectionV2 } from '../../StateSubmission/ReviewSubmit/V2/ReviewSubmit/SubmissionTypeSummarySectionV2' +import { + SubmissionUnlockedBanner, + SubmissionUpdatedBanner, + DocumentWarningBanner, +} from '../../../components' +import { usePage } from '../../../contexts/PageContext' +import { UpdateInformation, useFetchContractQuery } from '../../../gen/gqlClient' +import styles from '../SubmissionSummary.module.scss' +import { ChangeHistory } from '../../../components/ChangeHistory/ChangeHistory' +import { UnlockSubmitModal } from '../../../components/Modal/UnlockSubmitModal' +import { useLDClient } from 'launchdarkly-react-client-sdk' +import { featureFlags } from '../../../common-code/featureFlags' +import { SideNavOutletContextType } from '../../SubmissionSideNav/SubmissionSideNav' +import { RoutesRecord } from '../../../constants' +import { useRouteParams } from '../../../hooks' + +function UnlockModalButton({ + disabled, + modalRef, +}: { + disabled: boolean + modalRef: React.RefObject +}) { + return ( + + Unlock submission + + ) +} + +export const SubmissionSummaryV2 = (): React.ReactElement => { + // Page level state + const { updateHeading } = usePage() + const modalRef = useRef(null) + const [pkgName, setPkgName] = useState(undefined) + const [documentError, setDocumentError] = useState(false) + + useEffect(() => { + updateHeading({ customHeading: pkgName }) + }, [pkgName, updateHeading]) + const { id } = useRouteParams() + + // API requests + const { + data: fetchContractData, + loading: fetchContractLoading, + error: fetchContractError, + } = useFetchContractQuery({ + variables: { + input: { + contractID: id ?? 'unknown-contract', + }, + }, + }) + const ldClient = useLDClient() + const showQuestionResponse = ldClient?.variation( + featureFlags.CMS_QUESTIONS.flag, + featureFlags.CMS_QUESTIONS.defaultValue + ) + + const { pkg, currentRevision, packageData, user, documentDates } = + useOutletContext() + + const isCMSUser = user?.role === 'CMS_USER' + const submissionStatus = pkg.status + const statePrograms = pkg.state.programs + + // set the page heading + const name = packageName( + packageData.stateCode, + packageData.stateNumber, + packageData.programIDs, + statePrograms + ) + if (pkgName !== name) { + setPkgName(name) + } + + // Get the correct update info depending on the submission status + let updateInfo: UpdateInformation | undefined = undefined + if (submissionStatus === 'UNLOCKED' || submissionStatus === 'RESUBMITTED') { + updateInfo = + (submissionStatus === 'UNLOCKED' + ? pkg.revisions.find((rev) => rev.node.unlockInfo)?.node + .unlockInfo + : currentRevision.submitInfo) || undefined + } + + const disableUnlockButton = ['DRAFT', 'UNLOCKED'].includes(pkg.status) + + const isContractActionAndRateCertification = + packageData.submissionType === 'CONTRACT_AND_RATES' + + const handleDocumentDownloadError = (error: boolean) => + setDocumentError(error) + + const editOrAddMCCRSID = pkg.mccrsID + ? 'Edit MC-CRS number' + : 'Add MC-CRS record number' + + const contract = fetchContractData?.fetchContract.contract + return ( +
+
This is the V2 page of the SubmissionSummary
+ + {submissionStatus === 'UNLOCKED' && updateInfo && ( + + )} + + {submissionStatus === 'RESUBMITTED' && updateInfo && ( + + )} + + {documentError && ( + + )} + + {!showQuestionResponse && ( + + + {user?.__typename === 'StateUser' ? ( +  Back to state dashboard + ) : ( +  Back to dashboard + )} + + )} + + {contract && ( + + {pkg.mccrsID && ( + + MC-CRS record number: + + {pkg.mccrsID} + + + )} + + {editOrAddMCCRSID} + +
+ ) : undefined + } + contract={contract} + submissionName={name} + headerChildComponent={ + isCMSUser ? ( + + ) : undefined + } + statePrograms={statePrograms} + initiallySubmittedAt={pkg.initiallySubmittedAt} + /> + )} + + + + {contract && isContractActionAndRateCertification && ( + + )} + + {contract && ( + + )} + + + { + // if the session is expiring, close this modal so the countdown modal can appear + + } + + + ) +} + +export type SectionHeaderProps = { + header: string + submissionName?: boolean + href: string +} From 3e45343cdc43181198a7e69cc28185acfba70e37 Mon Sep 17 00:00:00 2001 From: pearl-truss Date: Tue, 2 Apr 2024 15:57:09 -0400 Subject: [PATCH 04/49] create and use v2 version of contract details summary section component --- .../StateSubmission/StateSubmissionForm.tsx | 3 +- .../V2/ContractDetailsSummarySectionV2.tsx | 321 ++++++++++++++++++ .../V2/SubmissionSummaryV2.tsx | 35 +- 3 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 services/app-web/src/pages/SubmissionSummary/V2/ContractDetailsSummarySectionV2.tsx diff --git a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx index 714520c2be..30539a497e 100644 --- a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx +++ b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx @@ -74,7 +74,8 @@ export const StateSubmissionForm = (): React.ReactElement => { 'SUBMISSIONS_REVIEW_SUBMIT' )} element={ - useLinkedRates ? : + + // useLinkedRates ? : } /> } /> diff --git a/services/app-web/src/pages/SubmissionSummary/V2/ContractDetailsSummarySectionV2.tsx b/services/app-web/src/pages/SubmissionSummary/V2/ContractDetailsSummarySectionV2.tsx new file mode 100644 index 0000000000..c850722919 --- /dev/null +++ b/services/app-web/src/pages/SubmissionSummary/V2/ContractDetailsSummarySectionV2.tsx @@ -0,0 +1,321 @@ +import React, { useState } from 'react' +import { DataDetail } from '../../../components/DataDetail' +import { SectionHeader } from '../../../components/SectionHeader' +import { UploadedDocumentsTable } from '../../../components/SubmissionSummarySection' +import { + ContractExecutionStatusRecord, + FederalAuthorityRecord, + ManagedCareEntityRecord, +} from '../../../constants/index' +import { + sortModifiedProvisions, + isMissingProvisions, + getProvisionDictionary, +} from '../../../common-code/ContractTypeProvisions' +import { + isBaseContract, + isCHIPOnly, + isContractWithProvisions, +} from '../../../common-code/ContractType' +import { useS3 } from '../../../contexts/S3Context' +import { formatCalendarDate } from '../../../common-code/dateHelpers' +import { DoubleColumnGrid } from '../../../components/DoubleColumnGrid' +import { DownloadButton } from '../../../components/DownloadButton' +import { usePreviousSubmission } from '../../../hooks/usePreviousSubmission' +import styles from '../../../components/SubmissionSummarySection/SubmissionSummarySection.module.scss' +import { DataDetailCheckboxList } from '../../../components/DataDetail/DataDetailCheckboxList' +import { + federalAuthorityKeysForCHIP, + CHIPFederalAuthority, +} from '../../../common-code/healthPlanFormDataType' +import { DocumentDateLookupTableType } from '../../../documentHelpers/makeDocumentDateLookupTable' +import { recordJSException } from '../../../otelHelpers' +import useDeepCompareEffect from 'use-deep-compare-effect' +import { InlineDocumentWarning } from '../../../components/DocumentWarning' +import { useLDClient } from 'launchdarkly-react-client-sdk' +import { featureFlags } from '../../../common-code/featureFlags' +import { Grid } from '@trussworks/react-uswds' +import { booleanAsYesNoFormValue } from '../../../components/Form/FieldYesNo' +import { + StatutoryRegulatoryAttestation, + StatutoryRegulatoryAttestationQuestion, +} from '../../../constants/statutoryRegulatoryAttestation' +import { SectionCard } from '../../../components/SectionCard' +import { Contract } from '../../../gen/gqlClient' + +export type ContractDetailsSummarySectionV2Props = { + contract: Contract + editNavigateTo?: string + documentDateLookupTable: DocumentDateLookupTableType + isCMSUser?: boolean + submissionName: string + onDocumentError?: (error: true) => void +} + +function renderDownloadButton(zippedFilesURL: string | undefined | Error) { + if (zippedFilesURL instanceof Error) { + return ( + + ) + } + return ( + + ) +} + +export const ContractDetailsSummarySectionV2 = ({ + contract, + editNavigateTo, // this is the edit link for the section. When this prop exists, summary section is loaded in edit mode + documentDateLookupTable, + submissionName, + onDocumentError, +}: ContractDetailsSummarySectionV2Props): React.ReactElement => { + // Checks if submission is a previous submission + const isPreviousSubmission = usePreviousSubmission() + // Get the zip file for the contract + const { getKey, getBulkDlURL } = useS3() + const [zippedFilesURL, setZippedFilesURL] = useState< + string | undefined | Error + >(undefined) + const ldClient = useLDClient() + + const contract438Attestation = ldClient?.variation( + featureFlags.CONTRACT_438_ATTESTATION.flag, + featureFlags.CONTRACT_438_ATTESTATION.defaultValue + ) + const isSubmitted = contract.status === 'SUBMITTED' + + const contractFormData = contract.packageSubmissions[0].contractRevision.formData + const attestationYesNo = + contractFormData.statutoryRegulatoryAttestation != null && + booleanAsYesNoFormValue(contractFormData.statutoryRegulatoryAttestation) + + const contractSupportingDocuments = contractFormData.supportingDocuments + const isEditing = !isSubmitted && editNavigateTo !== undefined + const applicableFederalAuthorities = isCHIPOnly(contract) + ? contractFormData.federalAuthorities.filter((authority) => + federalAuthorityKeysForCHIP.includes( + authority as CHIPFederalAuthority + ) + ) + : contractFormData.federalAuthorities + const [modifiedProvisions, unmodifiedProvisions] = + sortModifiedProvisions(contract) + const provisionsAreInvalid = isMissingProvisions(contract) && isEditing + + useDeepCompareEffect(() => { + // skip getting urls of this if this is a previous submission or draft + if (!isSubmitted || isPreviousSubmission) return + + // get all the keys for the documents we want to zip + async function fetchZipUrl() { + const keysFromDocs = contractFormData.contractDocuments + .concat(contractSupportingDocuments) + .map((doc) => { + const key = getKey(doc.s3URL) + if (!key) return '' + return key + }) + .filter((key) => key !== '') + + // call the lambda to zip the files and get the url + const zippedURL = await getBulkDlURL( + keysFromDocs, + submissionName + '-contract-details.zip', + 'HEALTH_PLAN_DOCS' + ) + if (zippedURL instanceof Error) { + const msg = `ERROR: getBulkDlURL failed to generate contract document URL. ID: ${contract.id} Message: ${zippedURL}` + console.info(msg) + + if (onDocumentError) { + onDocumentError(true) + } + + recordJSException(msg) + } + + setZippedFilesURL(zippedURL) + } + + void fetchZipUrl() + }, [ + getKey, + getBulkDlURL, + contract, + contractSupportingDocuments, + submissionName, + isPreviousSubmission, + ]) + + return ( + + + {isSubmitted && + !isPreviousSubmission && + renderDownloadButton(zippedFilesURL)} + +
+ {contract438Attestation && ( + + + {attestationYesNo !== false && + attestationYesNo !== undefined && + ()} + + {attestationYesNo === 'NO' && ( + + + + )} + + )} + + + + + } + /> + + } + /> + + {isContractWithProvisions(contract) && ( + + + {provisionsAreInvalid ? null : ( + + )} + + + + {provisionsAreInvalid ? null : ( + + )} + + + )} +
+ + +
+ ) +} diff --git a/services/app-web/src/pages/SubmissionSummary/V2/SubmissionSummaryV2.tsx b/services/app-web/src/pages/SubmissionSummary/V2/SubmissionSummaryV2.tsx index 871bd6ae34..5580ca84db 100644 --- a/services/app-web/src/pages/SubmissionSummary/V2/SubmissionSummaryV2.tsx +++ b/services/app-web/src/pages/SubmissionSummary/V2/SubmissionSummaryV2.tsx @@ -8,9 +8,6 @@ import { import React, { useEffect, useRef, useState } from 'react' import { NavLink, useOutletContext } from 'react-router-dom' import { packageName } from '../../../common-code/healthPlanFormDataType' -import { - ContractDetailsSummarySection, -} from '../../../components/SubmissionSummarySection' import { ContactsSummarySection } from '../../StateSubmission/ReviewSubmit/V2/ReviewSubmit/ContactsSummarySectionV2' import { RateDetailsSummarySectionV2 } from '../../StateSubmission/ReviewSubmit/V2/ReviewSubmit/RateDetailsSummarySectionV2' import { SubmissionTypeSummarySectionV2 } from '../../StateSubmission/ReviewSubmit/V2/ReviewSubmit/SubmissionTypeSummarySectionV2' @@ -19,6 +16,11 @@ import { SubmissionUpdatedBanner, DocumentWarningBanner, } from '../../../components' +import { + ErrorOrLoadingPage, + handleAndReturnErrorState, +} from '../../StateSubmission/ErrorOrLoadingPage' +import { ContractDetailsSummarySectionV2 } from './ContractDetailsSummarySectionV2' import { usePage } from '../../../contexts/PageContext' import { UpdateInformation, useFetchContractQuery } from '../../../gen/gqlClient' import styles from '../SubmissionSummary.module.scss' @@ -122,6 +124,17 @@ export const SubmissionSummaryV2 = (): React.ReactElement => { : 'Add MC-CRS record number' const contract = fetchContractData?.fetchContract.contract + + // Display any full page interim state resulting from the initial fetch API requests + if (fetchContractLoading) { + return + } + + if (fetchContractError) { + return ( + + ) + } return (
This is the V2 page of the SubmissionSummary
@@ -215,13 +228,15 @@ export const SubmissionSummaryV2 = (): React.ReactElement => { /> )} - + {contract && ( + + )} {contract && isContractActionAndRateCertification && ( Date: Tue, 2 Apr 2024 17:07:38 -0400 Subject: [PATCH 05/49] temp comment out reviewsubmitv2 for testing --- .../app-web/src/pages/StateSubmission/StateSubmissionForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx index 30539a497e..58905cc6d8 100644 --- a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx +++ b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx @@ -21,7 +21,7 @@ import { UnlockedHealthPlanFormDataType } from '../../common-code/healthPlanForm import { useLDClient } from 'launchdarkly-react-client-sdk' import { featureFlags } from '../../common-code/featureFlags' import { RateDetailsV2 } from './RateDetails/V2/RateDetailsV2' -import { ReviewSubmitV2 } from './ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2' +// import { ReviewSubmitV2 } from './ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2' import styles from './StateSubmissionForm.module.scss' // Can move this AppRoutes on future pass - leaving it here now to make diff clear @@ -74,7 +74,7 @@ export const StateSubmissionForm = (): React.ReactElement => { 'SUBMISSIONS_REVIEW_SUBMIT' )} element={ - + // useLinkedRates ? : } /> From e2dd1b0dcf3299d02fea0e5f8a45a015e8f2f5a8 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Tue, 2 Apr 2024 14:59:22 -0700 Subject: [PATCH 06/49] get submitContract working --- .../migration.sql | 60 +++++++ .../migration.sql | 17 ++ services/app-api/prisma/schema.prisma | 48 +++++- .../contractAndRates/contractTypes.ts | 3 + .../domain-models/contractAndRates/index.ts | 5 + .../contractAndRates/packageSubmissions.ts | 42 +++++ services/app-api/src/domain-models/index.ts | 2 + ...findAllContractsWithHistoryBySubmitInfo.ts | 4 +- .../findAllRatesWithHistoryBySubmitInfo.ts | 4 +- .../parseContractWithHistory.ts | 102 +++++++++-- .../prismaSubmittedContractHelpers.ts | 30 ++++ .../contractAndRates/submitContract.ts | 160 ++++++++++++++++++ .../updateDraftContractRates.ts | 84 ++++++++- .../src/resolvers/configureResolvers.ts | 9 + .../resolvers/contract/contractResolver.ts | 62 ++++++- .../resolvers/contract/submitContract.test.ts | 2 +- .../contract/updateDraftContractRates.ts | 14 +- services/app-graphql/codegen.yml | 1 + services/app-graphql/src/schema.graphql | 2 +- 19 files changed, 617 insertions(+), 34 deletions(-) create mode 100644 services/app-api/prisma/migrations/20240401220251_add_package_join_table/migration.sql create mode 100644 services/app-api/prisma/migrations/20240401231337_add_draft_rate_join_table/migration.sql create mode 100644 services/app-api/src/domain-models/contractAndRates/packageSubmissions.ts diff --git a/services/app-api/prisma/migrations/20240401220251_add_package_join_table/migration.sql b/services/app-api/prisma/migrations/20240401220251_add_package_join_table/migration.sql new file mode 100644 index 0000000000..492793007a --- /dev/null +++ b/services/app-api/prisma/migrations/20240401220251_add_package_join_table/migration.sql @@ -0,0 +1,60 @@ +BEGIN; +-- DropEnum +DROP TYPE "DocumentCategory"; + +-- CreateTable +CREATE TABLE "SubmissionPackageJoinTable" ( + "submissionID" TEXT NOT NULL, + "contractRevisionID" TEXT NOT NULL, + "rateRevisionID" TEXT NOT NULL, + "ratePosition" INTEGER NOT NULL, + + CONSTRAINT "SubmissionPackageJoinTable_pkey" PRIMARY KEY ("submissionID","contractRevisionID","rateRevisionID") +); + +-- CreateTable +CREATE TABLE "_ContractRevisionTableToUpdateInfoTable" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_RateRevisionTableToUpdateInfoTable" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_ContractRevisionTableToUpdateInfoTable_AB_unique" ON "_ContractRevisionTableToUpdateInfoTable"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ContractRevisionTableToUpdateInfoTable_B_index" ON "_ContractRevisionTableToUpdateInfoTable"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_RateRevisionTableToUpdateInfoTable_AB_unique" ON "_RateRevisionTableToUpdateInfoTable"("A", "B"); + +-- CreateIndex +CREATE INDEX "_RateRevisionTableToUpdateInfoTable_B_index" ON "_RateRevisionTableToUpdateInfoTable"("B"); + +-- AddForeignKey +ALTER TABLE "SubmissionPackageJoinTable" ADD CONSTRAINT "SubmissionPackageJoinTable_submissionID_fkey" FOREIGN KEY ("submissionID") REFERENCES "UpdateInfoTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubmissionPackageJoinTable" ADD CONSTRAINT "SubmissionPackageJoinTable_contractRevisionID_fkey" FOREIGN KEY ("contractRevisionID") REFERENCES "ContractRevisionTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubmissionPackageJoinTable" ADD CONSTRAINT "SubmissionPackageJoinTable_rateRevisionID_fkey" FOREIGN KEY ("rateRevisionID") REFERENCES "RateRevisionTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ContractRevisionTableToUpdateInfoTable" ADD CONSTRAINT "_ContractRevisionTableToUpdateInfoTable_A_fkey" FOREIGN KEY ("A") REFERENCES "ContractRevisionTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ContractRevisionTableToUpdateInfoTable" ADD CONSTRAINT "_ContractRevisionTableToUpdateInfoTable_B_fkey" FOREIGN KEY ("B") REFERENCES "UpdateInfoTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RateRevisionTableToUpdateInfoTable" ADD CONSTRAINT "_RateRevisionTableToUpdateInfoTable_A_fkey" FOREIGN KEY ("A") REFERENCES "RateRevisionTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RateRevisionTableToUpdateInfoTable" ADD CONSTRAINT "_RateRevisionTableToUpdateInfoTable_B_fkey" FOREIGN KEY ("B") REFERENCES "UpdateInfoTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMIT; diff --git a/services/app-api/prisma/migrations/20240401231337_add_draft_rate_join_table/migration.sql b/services/app-api/prisma/migrations/20240401231337_add_draft_rate_join_table/migration.sql new file mode 100644 index 0000000000..fe6b3e8d15 --- /dev/null +++ b/services/app-api/prisma/migrations/20240401231337_add_draft_rate_join_table/migration.sql @@ -0,0 +1,17 @@ +BEGIN; +-- CreateTable +CREATE TABLE "DraftRateJoinTable" ( + "contractID" TEXT NOT NULL, + "rateID" TEXT NOT NULL, + "ratePosition" INTEGER NOT NULL, + + CONSTRAINT "DraftRateJoinTable_pkey" PRIMARY KEY ("contractID","rateID") +); + +-- AddForeignKey +ALTER TABLE "DraftRateJoinTable" ADD CONSTRAINT "DraftRateJoinTable_contractID_fkey" FOREIGN KEY ("contractID") REFERENCES "ContractTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DraftRateJoinTable" ADD CONSTRAINT "DraftRateJoinTable_rateID_fkey" FOREIGN KEY ("rateID") REFERENCES "RateTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +COMMIT; diff --git a/services/app-api/prisma/schema.prisma b/services/app-api/prisma/schema.prisma index 62c7854c66..6d311e9e94 100644 --- a/services/app-api/prisma/schema.prisma +++ b/services/app-api/prisma/schema.prisma @@ -45,6 +45,8 @@ model ContractTable { questions Question[] + draftRates DraftRateJoinTable[] + revisions ContractRevisionTable[] // This relationship is a scam. We never call it in our code but Prisma // requires that there be an inverse to RateRevision.draftContracts which we do use @@ -62,12 +64,25 @@ model RateTable { state State @relation(fields: [stateCode], references: [stateCode]) stateNumber Int + draftContracts DraftRateJoinTable[] + revisions RateRevisionTable[] // This relationship is a scam. We never call it in our code but Prisma // requires that there be an inverse to ContractRevision.draftRates which we do use draftContractRevisions ContractRevisionTable[] } +// DraftRateJoinTable links a draft contract to a set of rates, draft or submitted. +model DraftRateJoinTable { + contractID String + contract ContractTable @relation(fields: [contractID], references: [id]) + rateID String + rate RateTable @relation(fields: [rateID], references: [id]) + ratePosition Int + + @@id([contractID, rateID]) +} + model ContractRevisionTable { id String @id @default(uuid()) contractID String @@ -81,6 +96,9 @@ model ContractRevisionTable { submitInfoID String? submitInfo UpdateInfoTable? @relation("submitContractInfo", fields: [submitInfoID], references: [id]) + relatedSubmisions UpdateInfoTable[] + submissionPackages SubmissionPackageJoinTable[] @relation("contractRevision") + createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -133,6 +151,9 @@ model RateRevisionTable { submitInfoID String? submitInfo UpdateInfoTable? @relation("submitRateInfo", fields: [submitInfoID], references: [id], onDelete: Cascade) + relatedSubmissions UpdateInfoTable[] + submissionPackages SubmissionPackageJoinTable[] @relation("rateRevision") + rateType RateType? rateCapitationType RateCapitationType? rateDocuments RateDocument[] @@ -169,10 +190,33 @@ model UpdateInfoTable { updatedByID String updatedBy User @relation(fields: [updatedByID], references: [id]) updatedReason String - unlockedContracts ContractRevisionTable[] @relation("unlockContractInfo") + + // the actually submitted contract/rates for this submission submittedContracts ContractRevisionTable[] @relation("submitContractInfo") - unlockedRates RateRevisionTable[] @relation("unlockRateInfo") submittedRates RateRevisionTable[] @relation("submitRateInfo") + + // These relations are inverse 1:1 relations that are not really used. + unlockedContracts ContractRevisionTable[] @relation("unlockContractInfo") + unlockedRates RateRevisionTable[] @relation("unlockRateInfo") + submissionPackages SubmissionPackageJoinTable[] @relation("submission") + + // these get ALL related contracts + revisions for given update. + relatedContracts ContractRevisionTable[] + relatedRates RateRevisionTable[] +} + +// SubmissionPackageJoinTable records the set of related rate and contracts for a given submission. +model SubmissionPackageJoinTable { + submissionID String + submission UpdateInfoTable @relation("submission", fields: [submissionID], references: [id]) + contractRevisionID String + contractRevision ContractRevisionTable @relation("contractRevision", fields: [contractRevisionID], references: [id]) + rateRevisionID String + rateRevision RateRevisionTable @relation("rateRevision", fields: [rateRevisionID], references: [id]) + // this number indicates the position of the rate in this contract + ratePosition Int + + @@id([submissionID, contractRevisionID, rateRevisionID]) } model ActuaryContact { diff --git a/services/app-api/src/domain-models/contractAndRates/contractTypes.ts b/services/app-api/src/domain-models/contractAndRates/contractTypes.ts index 1d0196b732..e7e73c4c3d 100644 --- a/services/app-api/src/domain-models/contractAndRates/contractTypes.ts +++ b/services/app-api/src/domain-models/contractAndRates/contractTypes.ts @@ -3,6 +3,7 @@ import { contractRevisionWithRatesSchema } from './revisionTypes' import { statusSchema } from './statusType' import { pruneDuplicateEmails } from '../../emailer/formatters' import { rateSchema } from './rateTypes' +import { contractPackageSubmissionSchema } from './packageSubmissions' // Contract represents the contract specific information in a submission package // All that data is contained in revisions, each revision represents the data in a single submission @@ -20,6 +21,8 @@ const contractSchema = z.object({ draftRates: z.array(rateSchema).optional(), // All revisions are submitted and in reverse chronological order revisions: z.array(contractRevisionWithRatesSchema), + + packageSubmissions: z.array(contractPackageSubmissionSchema), }) const draftContractSchema = contractSchema.extend({ diff --git a/services/app-api/src/domain-models/contractAndRates/index.ts b/services/app-api/src/domain-models/contractAndRates/index.ts index 5ffaed57cf..67d2d90b14 100644 --- a/services/app-api/src/domain-models/contractAndRates/index.ts +++ b/services/app-api/src/domain-models/contractAndRates/index.ts @@ -40,3 +40,8 @@ export type { RateRevisionWithContractsType, ContractRevisionWithRatesType, } from './revisionTypes' + +export type { + ContractPackageSubmissionType, + ContractPackageSubmissionWithCauseType, +} from './packageSubmissions' diff --git a/services/app-api/src/domain-models/contractAndRates/packageSubmissions.ts b/services/app-api/src/domain-models/contractAndRates/packageSubmissions.ts new file mode 100644 index 0000000000..ad3dfa15fc --- /dev/null +++ b/services/app-api/src/domain-models/contractAndRates/packageSubmissions.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { contractRevisionSchema, rateRevisionSchema } from './revisionTypes' +import { updateInfoSchema } from './updateInfoType' + +const contractPackageSubmissionSchema = z.object({ + submitInfo: updateInfoSchema, + submittedRevisions: z.array( + z.union([contractRevisionSchema, rateRevisionSchema]) + ), + contractRevision: contractRevisionSchema, + rateRevisions: z.array(rateRevisionSchema), +}) + +const packgeSubmissionCause = z.union([ + z.literal('CONTRACT_SUBMISSION'), + z.literal('RATE_SUBMISSION'), + z.literal('RATE_UNLINK'), + z.literal('RATE_LINK'), +]) + +const contractPackageSubmissionWithCauseSchema = + contractPackageSubmissionSchema.extend({ + cause: packgeSubmissionCause, + }) + +type ContractPackageSubmissionType = z.infer< + typeof contractPackageSubmissionSchema +> + +type ContractPackageSubmissionWithCauseType = z.infer< + typeof contractPackageSubmissionWithCauseSchema +> + +export { + contractPackageSubmissionSchema, + contractPackageSubmissionWithCauseSchema, +} + +export type { + ContractPackageSubmissionType, + ContractPackageSubmissionWithCauseType, +} diff --git a/services/app-api/src/domain-models/index.ts b/services/app-api/src/domain-models/index.ts index 92bc346650..c8cbb2d282 100644 --- a/services/app-api/src/domain-models/index.ts +++ b/services/app-api/src/domain-models/index.ts @@ -44,6 +44,8 @@ export type { RateRevisionWithContractsType, RateFormDataType, PackageStatusType, + ContractPackageSubmissionType, + ContractPackageSubmissionWithCauseType, } from './contractAndRates' export type { diff --git a/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.ts b/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.ts index ec6a1424ee..fab270c265 100644 --- a/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.ts +++ b/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.ts @@ -12,8 +12,8 @@ async function findAllContractsWithHistoryBySubmitInfo( where: { revisions: { some: { - submitInfo: { - isNot: null, + submitInfoID: { + not: null, }, }, }, diff --git a/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.ts b/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.ts index 3f8753951b..a97fde5d64 100644 --- a/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.ts +++ b/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.ts @@ -19,8 +19,8 @@ async function findAllRatesWithHistoryBySubmitInfo( where: { revisions: { some: { - submitInfo: { - isNot: null, + submitInfoID: { + not: null, }, }, }, diff --git a/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts b/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts index 298029d4f4..e1aa2f5ff4 100644 --- a/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts @@ -5,12 +5,15 @@ import type { RateType, } from '../../domain-models/contractAndRates' import { contractSchema } from '../../domain-models/contractAndRates' +import type { ContractPackageSubmissionType } from '../../domain-models/contractAndRates/packageSubmissions' +import { rateWithHistoryToDomainModel } from './parseRateWithHistory' import { draftContractRevToDomainModel } from './prismaDraftContractHelpers' import type { RateRevisionTableWithFormData, ContractRevisionTableWithFormData, UpdateInfoTableWithUpdater, } from './prismaSharedContractRateHelpers' +import { rateRevisionToDomainModel } from './prismaSharedContractRateHelpers' import { contractFormDataToDomainModel, convertUpdateInfoToDomainModel, @@ -112,7 +115,7 @@ function contractWithHistoryToDomainModel( const contractRevisions = contract.revisions let draftRevision: ContractRevisionWithRatesType | Error | undefined = undefined - let draftRates: RateType[] | Error | undefined = undefined + let draftRates: RateType[] | undefined = undefined for (const [contractRevIndex, contractRev] of contractRevisions.entries()) { // We set the draft revision aside, all ordered revisions are submitted @@ -132,19 +135,40 @@ function contractWithHistoryToDomainModel( ) } - const draftPrismaRates = contractRev.draftRates - - draftRates = draftPrismaRates.map((r) => { - return { - id: r.id, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - status: getContractRateStatus(r.revisions), - stateCode: r.stateCode, - stateNumber: r.stateNumber, - revisions: [], - } - }) + // if we have a draft revision, we should set draftRates + const draftRatesOrError = contract.draftRates.map((dr) => + rateWithHistoryToDomainModel(dr.rate) + ) + const firstError: Error | undefined = draftRatesOrError.find( + (dr): dr is Error => dr instanceof Error + ) + if (firstError) { + return firstError + } else { + const allDraftRates: RateType[] = + draftRatesOrError as RateType[] + draftRates = allDraftRates + } + + if (draftRates.length === 0) { + console.info( + 'Checking for old style draft rates, this code should go when migrated to new draft-rates table.' + ) + // This code works for pre-migrated stuff. + const draftPrismaRates = contractRev.draftRates + draftRates = draftPrismaRates.map((r) => { + return { + id: r.id, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + status: getContractRateStatus(r.revisions), + stateCode: r.stateCode, + stateNumber: r.stateNumber, + revisions: [], + } + }) + } + // skip the rest of the processing continue } @@ -274,6 +298,55 @@ function contractWithHistoryToDomainModel( ) } + // New C+R package history code + // Every revision has a set of submissions it was part of. + const packageSubmissions: ContractPackageSubmissionType[] = [] + for (const revision of contract.revisions) { + for (const submission of revision.relatedSubmisions) { + // submittedThings + const submittedContract = submission.submittedContracts.map((c) => + contractRevisionToDomainModel(c) + ) + const submittedRates = submission.submittedRates.map((r) => + rateRevisionToDomainModel(r) + ) + + const submitedRevs: ContractPackageSubmissionType['submittedRevisions'] = + [] + for (const contractRev of submittedContract) { + submitedRevs.push(contractRev) + } + for (const rateRev of submittedRates) { + if (rateRev instanceof Error) { + return rateRev + } + submitedRevs.push(rateRev) + } + + const relatedRateRevisions = submission.submissionPackages + .filter((p) => p.contractRevisionID === revision.id) + .map((p) => p.rateRevision) + + const rateRevisions = + ratesRevisionsToDomainModel(relatedRateRevisions) + + if (rateRevisions instanceof Error) { + return rateRevisions + } + + packageSubmissions.push({ + submitInfo: { + updatedAt: submission.updatedAt, + updatedBy: submission.updatedBy.email, + updatedReason: submission.updatedReason, + }, + submittedRevisions: submitedRevs, + contractRevision: contractRevisionToDomainModel(revision), + rateRevisions: rateRevisions, + }) + } + } + return { id: contract.id, createdAt: contract.createdAt, @@ -285,6 +358,7 @@ function contractWithHistoryToDomainModel( draftRevision, draftRates, revisions: revisions.reverse(), + packageSubmissions: packageSubmissions, } } diff --git a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts index 686068753d..a03e417ae6 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts @@ -4,6 +4,7 @@ import { includeContractFormData, includeRateFormData, } from './prismaSharedContractRateHelpers' +import { includeFullRate } from './prismaSubmittedRateHelpers' // Generated Types @@ -21,6 +22,16 @@ const includeLatestSubmittedRateRev = { // The include parameters for everything in a Contract. const includeFullContract = { + draftRates: { + orderBy: { + ratePosition: 'asc', + }, + include: { + rate: { + include: includeFullRate, + }, + }, + }, revisions: { orderBy: { createdAt: 'asc', @@ -28,6 +39,25 @@ const includeFullContract = { include: { ...includeContractFormData, + relatedSubmisions: { + include: { + submittedContracts: { + include: includeContractFormData, + }, + submittedRates: { + include: includeRateFormData, + }, + updatedBy: true, + submissionPackages: { + include: { + rateRevision: { + include: includeRateFormData, + }, + }, + }, + }, + }, + draftRates: { include: includeDraftRates, }, diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index 583014136d..a5858d4254 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -22,6 +22,21 @@ export async function submitContract( try { return await client.$transaction(async (tx) => { + // New C+R code + const currentContract = await findContractWithHistory( + tx, + contractID + ) + if (currentContract instanceof Error) { + return currentContract + } + + if (!currentContract.draftRevision || !currentContract.draftRates) { + return new Error( + 'Attempting to submit a contract that has no draft data' + ) + } + // find the current contract with related rates const currentRev = await client.contractRevisionTable.findFirst({ where: { @@ -168,6 +183,151 @@ export async function submitContract( }) } + // NEW C+R HISTORY CODE + // add an entry for this contract revision in the related submissions table + await tx.contractRevisionTable.update({ + where: { + id: currentRev.id, + }, + data: { + relatedSubmisions: { + connect: { + id: submitInfo.id, + }, + }, + }, + }) + + const relatedRateRevisionsIDs: { + rateID: string + revisionID: string + }[] = currentContract.draftRates.map((r) => { + // have to deal with the fact that the rate will have been submitted at this point but wasn't at the start + const lastRev = r.draftRevision || r.revisions[0] + return { + rateID: r.id, + revisionID: lastRev.id, + } + }) + + const disconnectedRateRevs = [] + // if there is a previous submission, add any removed rates from that previous submission to the pile + if (currentContract.packageSubmissions.length > 0) { + const pastSubmission = currentContract.packageSubmissions[0] + + // get all related rate revisions that need to be linked to this submission + const previousRateRevisions = pastSubmission.rateRevisions + for (const previousRateRevision of previousRateRevisions) { + if ( + !relatedRateRevisionsIDs.find( + (r) => r.rateID === previousRateRevision.rate.id + ) + ) { + relatedRateRevisionsIDs.push({ + rateID: previousRateRevision.rate.id, + revisionID: previousRateRevision.id, + }) + disconnectedRateRevs.push(previousRateRevision) + } + } + } + + // previously connected contracts + const allRelatedRateRevisionsBefore = + await tx.rateRevisionTable.findMany({ + where: { + id: { + in: relatedRateRevisionsIDs.map( + (rr) => rr.revisionID + ), + }, + }, + include: { + relatedSubmissions: { + include: { + submissionPackages: { + include: { + contractRevision: true, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + }, + }, + }) + + // now that we've fetched their current packages, write them to the newly related table. + await tx.updateInfoTable.update({ + where: { + id: submitInfo.id, + }, + data: { + relatedRates: { + connect: relatedRateRevisionsIDs.map((rr) => ({ + id: rr.revisionID, + })), + }, + }, + }) + + // now enter the new ones into the join table. + // the full set of currently connected rates to this contract + // plus any contracts that were connected to those rates, b/c those rates were changed with this submission. + + // Get all the rate -> contract links that need to be passed onto the new submission + const repeatedLinks: { + rateRevID: string + contractRevID: string + ratePosition: number + }[] = [] + for (const rateRev of allRelatedRateRevisionsBefore) { + // Find their last submission, and get all contracts that aren't this contract. + + if ( + rateRev.relatedSubmissions && + rateRev.relatedSubmissions.length > 0 + ) { + const latestSub = rateRev.relatedSubmissions[0] + + for (const contractConnection of latestSub.submissionPackages) { + if ( + contractConnection.contractRevision.contractID !== + currentContract.id + ) { + repeatedLinks.push({ + rateRevID: rateRev.id, + contractRevID: + contractConnection.contractRevision.id, + ratePosition: contractConnection.ratePosition, + }) + } + } + } + } + + let ratePosition = 0 + const newLinks = relatedRateRevisionsIDs.map((rr) => { + ratePosition++ + return { + rateRevID: rr.revisionID, + contractRevID: currentRev.id, + ratePosition, + } + }) + + const allLinks = repeatedLinks.concat(newLinks) + + await tx.submissionPackageJoinTable.createMany({ + data: allLinks.map((link) => ({ + submissionID: submitInfo.id, + contractRevisionID: link.contractRevID, + rateRevisionID: link.rateRevID, + ratePosition: link.ratePosition, + })), + }) + return await findContractWithHistory(tx, contractID) }) } catch (err) { diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts index 516f4fd96c..e0c0120227 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts @@ -8,13 +8,16 @@ import type { RateFormEditable } from './updateDraftRate' interface UpdatedRatesType { create: { formData: RateFormEditable + ratePosition: number }[] update: { rateID: string formData: RateFormEditable + ratePosition: number }[] link: { rateID: string + ratePosition: number }[] unlink: { rateID: string @@ -182,11 +185,14 @@ async function updateDraftContractRates( let nextRateNumber = state.latestStateRateCertNumber + 1 // create new rates with new revisions - const ratesToCreate = args.rateUpdates.create.map((ru) => { - const rateFormData = ru.formData + const createdRateJoins: { rateID: string; ratePosition: number }[] = + [] + for (const createRateArg of args.rateUpdates.create) { + const rateFormData = createRateArg.formData const thisRateNumber = nextRateNumber nextRateNumber++ - return { + + const rateToCreate = { stateCode: contract.stateCode, stateNumber: thisRateNumber, revisions: { @@ -195,7 +201,19 @@ async function updateDraftContractRates( ), }, } - }) + + const createdRate = await tx.rateTable.create({ + data: rateToCreate, + include: { + revisions: true, + }, + }) + + createdRateJoins.push({ + rateID: createdRate.id, + ratePosition: createRateArg.ratePosition, + }) + } // to delete draft rates, we need to delete their revisions first await tx.rateRevisionTable.deleteMany({ @@ -206,14 +224,19 @@ async function updateDraftContractRates( }, }) + const oldLinksToCreate = [ + ...createdRateJoins.map((lr) => lr.rateID), + ...args.rateUpdates.link.map((ru) => ru.rateID), + ] + // create new rates and link and unlink others await tx.contractRevisionTable.update({ where: { id: draftRevision.id }, data: { draftRates: { - create: ratesToCreate, - connect: args.rateUpdates.link.map((ru) => ({ - id: ru.rateID, + // create: ratesToCreate, + connect: oldLinksToCreate.map((rID) => ({ + id: rID, })), disconnect: args.rateUpdates.unlink.map((ru) => ({ id: ru.rateID, @@ -228,6 +251,53 @@ async function updateDraftContractRates( }, }) + // new rate + contract Linking tables + + // for each of the links, we have to get the order right + // all the newly valid links are from create/update/link + const links: { rateID: string; ratePosition: number }[] = [ + ...createdRateJoins.map((rj) => ({ + rateID: rj.rateID, + ratePosition: rj.ratePosition, + })), + ...args.rateUpdates.update.map((ru) => ({ + rateID: ru.rateID, + ratePosition: ru.ratePosition, + })), + ...args.rateUpdates.link.map((ru) => ({ + rateID: ru.rateID, + ratePosition: ru.ratePosition, + })), + ] + + // Check our work, these should be an incrementing list of ratePositions. + const ratePositions = links.map((l) => l.ratePosition).sort() + let lastPosition = 0 + for (const ratePosition of ratePositions) { + if (ratePosition !== lastPosition + 1) { + console.error( + 'Updated Rate ratePositions Are Not Ordered', + ratePositions + ) + return new Error( + 'updateDraftContractRates called with discontinuous order ratePositions' + ) + } + lastPosition++ + } + + await tx.contractTable.update({ + where: { id: args.contractID }, + data: { + draftRates: { + deleteMany: {}, + create: links, + }, + }, + }) + + // end new R+C Contract Linking Tables + // update existing rates for (const ru of args.rateUpdates.update) { const draftRev = await tx.rateRevisionTable.findFirst({ diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index bef54084b8..75a9aa4b05 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -119,6 +119,15 @@ export function configureResolvers( } }, }, + SubmittableRevision: { + __resolveType(obj) { + if ('contract' in obj) { + return 'ContractRevision' + } else { + return 'RateRevision' + } + }, + }, StateUser: stateUserResolver, CMSUser: cmsUserResolver, HealthPlanPackage: healthPlanPackageResolver(store), diff --git a/services/app-api/src/resolvers/contract/contractResolver.ts b/services/app-api/src/resolvers/contract/contractResolver.ts index 76b3072798..1b5eafd786 100644 --- a/services/app-api/src/resolvers/contract/contractResolver.ts +++ b/services/app-api/src/resolvers/contract/contractResolver.ts @@ -1,9 +1,10 @@ import statePrograms from '../../../../app-web/src/common-code/data/statePrograms.json' -import type { Resolvers } from '../../gen/gqlServer' +import type { Resolvers, SubmissionReason } from '../../gen/gqlServer' import { logError } from '../../logger' import type { Store } from '../../postgres' import { GraphQLError } from 'graphql' import { setErrorAttributesOnActiveSpan } from '../attributeHelper' +import type { ContractPackageSubmissionWithCauseType } from '../../domain-models' export function contractResolver(store: Store): Resolvers['Contract'] { return { @@ -67,8 +68,63 @@ export function contractResolver(store: Store): Resolvers['Contract'] { }) }, // not yet implemented, currently only working on drafts: - packageSubmissions() { - return [] + packageSubmissions(parent) { + const gqlSubs: ContractPackageSubmissionWithCauseType[] = [] + for (let i = 0; i < parent.packageSubmissions.length; i++) { + const thisSub = parent.packageSubmissions[i] + let prevSub = undefined + if (i < parent.packageSubmissions.length - 1) { + prevSub = parent.packageSubmissions[i + 1] + } + + // determine the cause for this submission + let cause: SubmissionReason = 'CONTRACT_SUBMISSION' + + if ( + !thisSub.submittedRevisions.find( + (r) => r.id === thisSub.contractRevision.id + ) + ) { + // not a contract submission, this contract wasn't in the submitted bits + const connectedRateRevisionIDs = thisSub.rateRevisions.map( + (r) => r.id + ) + const submittedRate = thisSub.submittedRevisions.find((r) => + connectedRateRevisionIDs.includes(r.id) + ) + + if (!submittedRate) { + cause = 'RATE_UNLINK' + } else { + if (!prevSub) { + throw new Error( + 'Programming Error: a non-contract submission must have a previous contract submission' + ) + } + const previousRateRevisionIDs = + prevSub.rateRevisions.map((r) => r.id) + if ( + previousRateRevisionIDs.includes(submittedRate.id) + ) { + cause = 'RATE_SUBMISSION' + } else { + cause = 'RATE_LINK' + } + } + } + + const gqlSub: ContractPackageSubmissionWithCauseType = { + cause, + submitInfo: thisSub.submitInfo, + submittedRevisions: thisSub.submittedRevisions, + contractRevision: thisSub.contractRevision, + rateRevisions: thisSub.rateRevisions, + } + + gqlSubs.push(gqlSub) + } + + return gqlSubs }, } } diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts index 901232aae5..1dd1df66ea 100644 --- a/services/app-api/src/resolvers/contract/submitContract.test.ts +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -106,7 +106,7 @@ describe('submitContract', () => { expect(contract.draftContact).toBeUndefined() - expect(contract.packageSubmissionHistory).toHaveLength(1) + expect(contract.packageSubmissions).toHaveLength(1) throw new Error('INCOMEONWE') }) diff --git a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts index 0046437da8..353c67ee19 100644 --- a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts +++ b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts @@ -145,9 +145,13 @@ function updateDraftContractRates( // TODO set the position in the list correctly // any rates that aren't in this list are unlinked (or deleted?) const knownRateIDs = draftRates.map((r) => r.id) + let thisPosition = 1 for (const rateUpdate of parsedUpdates) { if (rateUpdate.type === 'CREATE') { - rateUpdates.create.push({ formData: rateUpdate.formData }) + rateUpdates.create.push({ + formData: rateUpdate.formData, + ratePosition: thisPosition, + }) } if (rateUpdate.type === 'UPDATE') { @@ -212,6 +216,7 @@ function updateDraftContractRates( rateUpdates.update.push({ rateID: rateUpdate.rateID, formData: rateUpdate.formData, + ratePosition: thisPosition, }) } @@ -254,8 +259,13 @@ function updateDraftContractRates( } // this is a new link, actually link them. - rateUpdates.link.push({ rateID: rateUpdate.rateID }) + rateUpdates.link.push({ + rateID: rateUpdate.rateID, + ratePosition: thisPosition, + }) } + + thisPosition++ } // we've gone through the existing rates, anything we didn't see we should remove diff --git a/services/app-graphql/codegen.yml b/services/app-graphql/codegen.yml index 0460629aba..9a35fe5107 100644 --- a/services/app-graphql/codegen.yml +++ b/services/app-graphql/codegen.yml @@ -24,6 +24,7 @@ generates: RateRevision: ../domain-models#RateRevisionType Contract: ../domain-models#ContractType as ContractDomainType ContractRevision: ../domain-models#ContractRevisionType + ContractPackageSubmission: ../domain-models#ContractPackageSubmissionWithCauseType ../app-web/src/gen/gqlClient.tsx: documents: - src/queries/*.graphql diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index eba7886b7e..a3148f9cea 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -1292,7 +1292,7 @@ type RateRevision { "The rate related form data that was inputed by the state" formData: RateFormData! "Contract revisions related to the rate" - contractRevisions: [RelatedContractRevisions!]! + contractRevisions: [RelatedContractRevisions!] } """ From a36eb527c3eb21bdc416bda6a25671b6f5adaf28 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Wed, 3 Apr 2024 12:43:40 -0700 Subject: [PATCH 07/49] add test helpers for contracts, test single linked rate --- .../findContractWithHistory.ts | 2 +- .../src/resolvers/configureResolvers.ts | 7 +- .../resolvers/contract/submitContract.test.ts | 150 ++++++---------- .../src/resolvers/contract/submitContract.ts | 47 +++-- .../submitHealthPlanPackage.ts | 91 +++++----- .../src/testHelpers/contractDataMocks.ts | 2 + .../src/testHelpers/gqlContractHelpers.ts | 142 ++++++++++++++- .../app-api/src/testHelpers/gqlRateHelpers.ts | 169 +++++++++++++++++- .../src/mutations/submitContract.graphql | 3 + .../src/queries/fetchContract.graphql | 3 + services/app-graphql/src/schema.graphql | 1 + 11 files changed, 446 insertions(+), 171 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts index ab809a8dcb..340ee25242 100644 --- a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts @@ -13,7 +13,7 @@ async function findContractWithHistory( contractID: string ): Promise { try { - const contract = await client.contractTable.findFirst({ + const contract = await client.contractTable.findUnique({ where: { id: contractID, }, diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 75a9aa4b05..81c0633a22 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -78,7 +78,12 @@ export function configureResolvers( emailParameterStore, launchDarkly ), - submitContract: submitContract(store), + submitContract: submitContract( + store, + emailer, + emailParameterStore, + launchDarkly + ), unlockHealthPlanPackage: unlockHealthPlanPackageResolver( store, emailer, diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts index 1dd1df66ea..fe9a02865a 100644 --- a/services/app-api/src/resolvers/contract/submitContract.test.ts +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -1,114 +1,78 @@ -import { - constructTestPostgresServer, - createTestHealthPlanPackage, -} from '../../testHelpers/gqlHelpers' -import UPDATE_DRAFT_CONTRACT_RATES from '../../../../app-graphql/src/mutations/updateDraftContractRates.graphql' +import { constructTestPostgresServer } from '../../testHelpers/gqlHelpers' import SUBMIT_CONTRACT from '../../../../app-graphql/src/mutations/submitContract.graphql' import { testCMSUser } from '../../testHelpers/userHelpers' import type { SubmitContractInput } from '../../gen/gqlServer' +import { + createAndSubmitTestContractWithRate, + createAndUpdateTestContractWithoutRates, + submitTestContract, +} from '../../testHelpers/gqlContractHelpers' +import { + addLinkedRateToTestContract, + addNewRateToTestContract, + fetchTestRateById, +} from '../../testHelpers/gqlRateHelpers' describe('submitContract', () => { it('submits a contract', async () => { const stateServer = await constructTestPostgresServer() - const draft = await createTestHealthPlanPackage(stateServer) - - const result = await stateServer.executeOperation({ - query: UPDATE_DRAFT_CONTRACT_RATES, - variables: { - input: { - contractID: draft.id, - updatedRates: [ - { - type: 'CREATE', - formData: { - rateType: 'AMENDMENT', - rateCapitationType: 'RATE_CELL', - rateDateStart: '2024-01-01', - rateDateEnd: '2025-01-01', - amendmentEffectiveDateStart: '2024-02-01', - amendmentEffectiveDateEnd: '2025-02-01', - rateProgramIDs: ['foo'], - - rateDocuments: [ - { - s3URL: 'foo://bar', - name: 'ratedoc1.doc', - sha256: 'foobar', - }, - ], - supportingDocuments: [ - { - s3URL: 'foo://bar1', - name: 'ratesupdoc1.doc', - sha256: 'foobar1', - }, - { - s3URL: 'foo://bar2', - name: 'ratesupdoc2.doc', - sha256: 'foobar2', - }, - ], - certifyingActuaryContacts: [ - { - name: 'Foo Person', - titleRole: 'Bar Job', - email: 'foo@example.com', - actuarialFirm: 'GUIDEHOUSE', - }, - ], - addtlActuaryContacts: [ - { - name: 'Bar Person', - titleRole: 'Baz Job', - email: 'bar@example.com', - actuarialFirm: 'OTHER', - actuarialFirmOther: 'Some Firm', - }, - ], - actuaryCommunicationPreference: - 'OACT_TO_ACTUARY', - packagesWithSharedRateCerts: [], - }, - }, - ], - }, - }, - }) - - expect(result.errors).toBeUndefined() - if (!result.data) { - throw new Error('No data returned') - } + const draft = await createAndUpdateTestContractWithoutRates(stateServer) + const draftWithRates = await addNewRateToTestContract( + stateServer, + draft + ) - const draftRates = - result.data.updateDraftContractRates.contract.draftRates + const draftRates = draftWithRates.draftRates expect(draftRates).toHaveLength(1) - // SUBMIT - const submitResult = await stateServer.executeOperation({ - query: SUBMIT_CONTRACT, - variables: { - input: { - contractID: draft.id, - submittedReason: 'FIRST POST', - }, - }, - }) + const contract = await submitTestContract(stateServer, draft.id) - expect(submitResult.errors).toBeUndefined() - if (!submitResult.data) { - throw new Error('no data') + expect(contract.draftRevision).toBeNull() + + expect(contract.packageSubmissions).toHaveLength(1) + + const sub = contract.packageSubmissions[0] + expect(sub.cause).toBe('CONTRACT_SUBMISSION') + expect(sub.submitInfo.updatedReason).toBe('Initial submission') + expect(sub.submittedRevisions).toHaveLength(2) + expect(sub.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const rateID = sub.rateRevisions[0].rate!.id + const rate = await fetchTestRateById(stateServer, rateID) + expect(rate.status).toBe('SUBMITTED') + }) + + it('handles a submission with multiple connections', async () => { + const stateServer = await constructTestPostgresServer() + + const contract1 = await createAndSubmitTestContractWithRate(stateServer) + const rate1 = contract1.packageSubmissions[0].rateRevisions[0].rate + if (!rate1) { + throw new Error('NO RATE') } - const contract = submitResult.data.submitContract.contract + const draft2 = + await createAndUpdateTestContractWithoutRates(stateServer) + await addLinkedRateToTestContract(stateServer, draft2, rate1.id) + const contract2 = await submitTestContract(stateServer, draft2.id) - expect(contract.draftContact).toBeUndefined() + expect(contract2.draftRevision).toBeNull() - expect(contract.packageSubmissions).toHaveLength(1) + expect(contract2.packageSubmissions).toHaveLength(1) - throw new Error('INCOMEONWE') + const sub = contract2.packageSubmissions[0] + expect(sub.cause).toBe('CONTRACT_SUBMISSION') + expect(sub.submitInfo.updatedReason).toBe('Initial submission') + expect(sub.submittedRevisions).toHaveLength(1) + expect(sub.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(sub.rateRevisions).toHaveLength(1) }) it('returns an error if a CMS user attempts to call submitContract', async () => { diff --git a/services/app-api/src/resolvers/contract/submitContract.ts b/services/app-api/src/resolvers/contract/submitContract.ts index bb573b500a..31f5d3630b 100644 --- a/services/app-api/src/resolvers/contract/submitContract.ts +++ b/services/app-api/src/resolvers/contract/submitContract.ts @@ -1,34 +1,33 @@ -import { ForbiddenError } from 'apollo-server-core' -import { isStateUser } from '../../domain-models' +import type { Emailer } from '../../emailer' import type { MutationResolvers } from '../../gen/gqlServer' +import type { LDService } from '../../launchDarkly/launchDarkly' +import type { EmailParameterStore } from '../../parameterStore' import type { Store } from '../../postgres' -import { - setErrorAttributesOnActiveSpan, - setResolverDetailsOnActiveSpan, -} from '../attributeHelper' +import { submitHealthPlanPackageResolver } from '../healthPlanPackage' export function submitContract( - store: Store + store: Store, + emailer: Emailer, + emailParameterStore: EmailParameterStore, + launchDarkly: LDService ): MutationResolvers['submitContract'] { - return async (_parent, { input }, context) => { - const { user, span } = context - setResolverDetailsOnActiveSpan('submitContract', user, span) + return async (parent, { input }, context) => { + // For some reason the types for resolvers are not actually callable? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const submitHPPResolver = submitHealthPlanPackageResolver( + store, + emailer, + emailParameterStore, + launchDarkly + ) as any - // This resolver is only callable by state users - if (!isStateUser(user)) { - const errMsg = - 'submitContract: user not authorized to create state data' - setErrorAttributesOnActiveSpan(errMsg, span) - throw new ForbiddenError('user not authorized to create state data') - } - - const realSubmitReason = input.submittedReason || 'Initial Submit' + await submitHPPResolver( + parent, + { input: { pkgID: input.contractID } }, + context + ) - const contract = await store.submitContract({ - contractID: input.contractID, - submittedReason: realSubmitReason, - submittedByUserID: context.user.id, - }) + const contract = await store.findContractWithHistory(input.contractID) if (contract instanceof Error) { throw contract diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index 1b2e10b985..6b12e06dd4 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -43,10 +43,7 @@ import { convertContractWithRatesToUnlockedHPP, } from '../../domain-models/contractAndRates/convertContractWithRatesToHPP' import type { Span } from '@opentelemetry/api' -import type { - PackageStatusType, - RateType, -} from '../../domain-models/contractAndRates' +import type { PackageStatusType } from '../../domain-models/contractAndRates' import type { RateFormEditable } from '../../postgres/contractAndRates/updateDraftRate' export const SubmissionErrorCodes = ['INCOMPLETE', 'INVALID'] as const @@ -369,49 +366,49 @@ export function submitHealthPlanPackageResolver( // From this point forward we use updateResult instead of contractWithHistory because it is now old data. // If there are rates, submit those first - if (updateResult.draftRevision.rateRevisions.length > 0) { - const ratePromises: Promise[] = [] - updateResult.draftRevision.rateRevisions.forEach((rateRev) => { - ratePromises.push( - store.submitRate({ - rateRevisionID: rateRev.id, - submittedByUserID: user.id, - submittedReason: updateInfo.updatedReason, - }) - ) - }) - - const submitRatesResult = await Promise.all(ratePromises) - // 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, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } - } + // if (updateResult.draftRevision.rateRevisions.length > 0) { + // const ratePromises: Promise[] = [] + // updateResult.draftRevision.rateRevisions.forEach((rateRev) => { + // ratePromises.push( + // store.submitRate({ + // rateRevisionID: rateRev.id, + // submittedByUserID: user.id, + // submittedReason: updateInfo.updatedReason, + // }) + // ) + // }) + + // const submitRatesResult = await Promise.all(ratePromises) + // // 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, { + // extensions: { + // code: 'INTERNAL_SERVER_ERROR', + // cause: 'DB_ERROR', + // }, + // }) + // } + // } // then submit the contract! const submitContractResult = await store.submitContract({ diff --git a/services/app-api/src/testHelpers/contractDataMocks.ts b/services/app-api/src/testHelpers/contractDataMocks.ts index df58119826..07506ec522 100644 --- a/services/app-api/src/testHelpers/contractDataMocks.ts +++ b/services/app-api/src/testHelpers/contractDataMocks.ts @@ -49,6 +49,7 @@ const mockContractData = ( mccrsID: null, stateCode: 'MN', stateNumber: 111, + draftRates: [], revisions: [], ...contract, } @@ -84,6 +85,7 @@ const mockContractRevision = ( ...defaultContractData(), ...contract, }, + relatedSubmisions: [], createdAt: new Date(), updatedAt: new Date(), submitInfo: { diff --git a/services/app-api/src/testHelpers/gqlContractHelpers.ts b/services/app-api/src/testHelpers/gqlContractHelpers.ts index 961f8d59f9..cd52e8d0d0 100644 --- a/services/app-api/src/testHelpers/gqlContractHelpers.ts +++ b/services/app-api/src/testHelpers/gqlContractHelpers.ts @@ -1,22 +1,33 @@ +import FETCH_CONTRACT from '../../../app-graphql/src/queries/fetchContract.graphql' +import SUBMIT_CONTRACT from '../../../app-graphql/src/mutations/submitContract.graphql' import { findStatePrograms } from '../postgres' import type { InsertContractArgsType } from '../postgres/contractAndRates/insertContract' import { must } from './assertionHelpers' -import { defaultFloridaProgram } from './gqlHelpers' +import { + createTestHealthPlanPackage, + defaultFloridaProgram, + updateTestHealthPlanFormData, +} from './gqlHelpers' import { mockInsertContractArgs, mockContractData } from './contractDataMocks' import { sharedTestPrismaClient } from './storeHelpers' import { insertDraftContract } from '../postgres/contractAndRates/insertContract' import type { ContractType } from '../domain-models' +import type { ApolloServer } from 'apollo-server-lambda' +import type { Contract } from '../gen/gqlServer' +import { latestFormData } from './healthPlanPackageHelpers' +import type { StateCodeType } from 'app-web/src/common-code/healthPlanFormDataType' +import { addNewRateToTestContract } from './gqlRateHelpers' const createAndSubmitTestContract = async ( contractData?: InsertContractArgsType ): Promise => { const contract = await createTestContract(contractData) - return await must(submitTestContract(contract)) + return await must(submitTestContractWithDB(contract)) } -const submitTestContract = async ( +const submitTestContractWithDB = async ( contractData?: Partial ): Promise => { const prismaClient = await sharedTestPrismaClient() @@ -40,6 +51,63 @@ const submitTestContract = async ( return must(await insertDraftContract(prismaClient, draftContractData)) } +async function submitTestContract( + server: ApolloServer, + contractID: string +): Promise { + const result = await server.executeOperation({ + query: SUBMIT_CONTRACT, + variables: { + input: { + contractID: contractID, + }, + }, + }) + + if (result.errors) { + throw new Error( + `fetchTestRateById query failed with errors ${result.errors}` + ) + } + + if (!result.data) { + throw new Error('fetchTestRateById returned nothing') + } + + return result.data.submitContract.contract +} + +async function createAndSubmitTestContractWithRate( + server: ApolloServer +): Promise { + const draft = await createAndUpdateTestContractWithRate(server) + + return await submitTestContract(server, draft.id) +} + +async function fetchTestContract( + server: ApolloServer, + contractID: string +): Promise { + const input = { contractID } + const result = await server.executeOperation({ + query: FETCH_CONTRACT, + variables: { input }, + }) + + if (result.errors) { + throw new Error( + `fetchTestRateById query failed with errors ${result.errors}` + ) + } + + if (!result.data) { + throw new Error('fetchTestRateById returned nothing') + } + + return result.data.fetchContract.contract +} + // USING PRISMA DIRECTLY BELOW --- we have no createContract resolver yet, but we have integration tests needing the workflows const createTestContract = async ( contractData?: Partial @@ -65,4 +133,70 @@ const createTestContract = async ( return must(await insertDraftContract(prismaClient, draftContractData)) } -export { createTestContract, createAndSubmitTestContract } +async function createAndUpdateTestContractWithRate( + server: ApolloServer +): Promise { + const draft = await createAndUpdateTestContractWithoutRates(server) + return await addNewRateToTestContract(server, draft) +} + +const createAndUpdateTestContractWithoutRates = async ( + server: ApolloServer, + stateCode?: StateCodeType +): Promise => { + const pkg = await createTestHealthPlanPackage(server, stateCode) + const draft = latestFormData(pkg) + + ;(draft.submissionType = 'CONTRACT_AND_RATES' as const), + (draft.submissionDescription = 'An updated submission') + draft.stateContacts = [ + { + name: 'test name', + titleRole: 'test title', + email: 'email@example.com', + }, + ] + draft.rateInfos = [] + draft.contractType = 'BASE' as const + draft.contractExecutionStatus = 'EXECUTED' as const + draft.contractDateStart = new Date(Date.UTC(2025, 5, 1)) + draft.contractDateEnd = new Date(Date.UTC(2026, 4, 30)) + draft.contractDocuments = [ + { + name: 'contractDocument.pdf', + s3URL: 'fakeS3URL', + sha256: 'fakesha', + }, + ] + draft.managedCareEntities = ['MCO'] + draft.federalAuthorities = ['STATE_PLAN' as const] + draft.populationCovered = 'MEDICAID' as const + draft.contractAmendmentInfo = { + modifiedProvisions: { + inLieuServicesAndSettings: true, + modifiedRiskSharingStrategy: false, + modifiedIncentiveArrangements: false, + modifiedWitholdAgreements: false, + modifiedStateDirectedPayments: true, + modifiedPassThroughPayments: true, + modifiedPaymentsForMentalDiseaseInstitutions: true, + modifiedNonRiskPaymentArrangements: true, + }, + } + draft.statutoryRegulatoryAttestation = false + draft.statutoryRegulatoryAttestationDescription = 'No compliance' + + await updateTestHealthPlanFormData(server, draft) + const updatedContract = await fetchTestContract(server, draft.id) + return updatedContract +} + +export { + createTestContract, + submitTestContract, + createAndSubmitTestContract, + fetchTestContract, + createAndUpdateTestContractWithoutRates, + createAndUpdateTestContractWithRate, + createAndSubmitTestContractWithRate, +} diff --git a/services/app-api/src/testHelpers/gqlRateHelpers.ts b/services/app-api/src/testHelpers/gqlRateHelpers.ts index c3c6d300ee..8e7ddbe7d6 100644 --- a/services/app-api/src/testHelpers/gqlRateHelpers.ts +++ b/services/app-api/src/testHelpers/gqlRateHelpers.ts @@ -14,7 +14,11 @@ import { sharedTestPrismaClient } from './storeHelpers' import { insertDraftRate } from '../postgres/contractAndRates/insertRate' import { updateDraftRate } from '../postgres/contractAndRates/updateDraftRate' -import type { Contract, RateFormDataInput } from '../gen/gqlServer' +import type { + Contract, + RateFormDataInput, + UpdateDraftContractRatesInput, +} from '../gen/gqlServer' import type { RateType } from '../domain-models' import type { InsertRateArgsType } from '../postgres/contractAndRates/insertRate' import type { RateFormEditable } from '../postgres/contractAndRates/updateDraftRate' @@ -134,6 +138,166 @@ const createTestRate = async ( return must(await insertDraftRate(prismaClient, draftRateData)) } +async function updateDraftRatesOnContract( + server: ApolloServer, + input: UpdateDraftContractRatesInput +): Promise { + const updateResult = await server.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input, + }, + }) + + if (updateResult.errors || !updateResult.data) { + throw new Error( + `updateDraftContractRates mutation failed with errors ${updateResult.errors}` + ) + } + + return updateResult.data.updateDraftContractRates.contract +} + +async function addNewRateToTestContract( + server: ApolloServer, + contract: Contract +): Promise { + const rateUpdateInput = updateRatesInputFromDraftContract(contract) + + const addedInput = addNewRateToRateInput(rateUpdateInput) + + return await updateDraftRatesOnContract(server, addedInput) +} + +function addNewRateToRateInput( + input: UpdateDraftContractRatesInput +): UpdateDraftContractRatesInput { + return { + contractID: input.contractID, + updatedRates: [ + ...input.updatedRates, + { + type: 'CREATE' as const, + formData: { + rateType: 'AMENDMENT', + rateCapitationType: 'RATE_CELL', + rateDateStart: '2024-01-01', + rateDateEnd: '2025-01-01', + rateDateCertified: '2024-01-02', + amendmentEffectiveDateStart: '2024-02-01', + amendmentEffectiveDateEnd: '2025-02-01', + rateProgramIDs: [defaultFloridaRateProgram().id], + + rateDocuments: [ + { + s3URL: 'foo://bar', + name: 'ratedoc1.doc', + sha256: 'foobar', + }, + ], + supportingDocuments: [ + { + s3URL: 'foo://bar1', + name: 'ratesupdoc1.doc', + sha256: 'foobar1', + }, + { + s3URL: 'foo://bar2', + name: 'ratesupdoc2.doc', + sha256: 'foobar2', + }, + ], + certifyingActuaryContacts: [ + { + name: 'Foo Person', + titleRole: 'Bar Job', + email: 'foo@example.com', + actuarialFirm: 'GUIDEHOUSE', + }, + ], + addtlActuaryContacts: [ + { + name: 'Bar Person', + titleRole: 'Baz Job', + email: 'bar@example.com', + actuarialFirm: 'OTHER', + actuarialFirmOther: 'Some Firm', + }, + ], + actuaryCommunicationPreference: 'OACT_TO_ACTUARY', + packagesWithSharedRateCerts: [], + }, + }, + ], + } +} + +async function addLinkedRateToTestContract( + server: ApolloServer, + contract: Contract, + rateID: string +): Promise { + const rateUpdateInput = updateRatesInputFromDraftContract(contract) + + const addedInput = addLinkedRateToRateInput(rateUpdateInput, rateID) + + return await updateDraftRatesOnContract(server, addedInput) +} + +function addLinkedRateToRateInput( + input: UpdateDraftContractRatesInput, + rateID: string +): UpdateDraftContractRatesInput { + return { + contractID: input.contractID, + updatedRates: [ + ...input.updatedRates, + { + type: 'LINK' as const, + rateID, + }, + ], + } +} + +function updateRatesInputFromDraftContract( + contract: Contract +): UpdateDraftContractRatesInput { + const draftRates = contract.draftRates + if (!draftRates) { + throw new Error('attempted to grab rate input from non-draft contract') + } + + const rateInputs = draftRates.map((rate) => { + if (rate.revisions.length > 0) { + // this is a linked rate TODO: Fix for proper parentage. (the rate will have been submitted initially with this contract) + return { + type: 'LINK' as const, + rateID: rate.id, + } + } else { + const revision = rate.draftRevision + if (!revision) { + console.error( + 'programming error no revision found for rate', + rate + ) + throw new Error('No revision found for rate') + } + return { + type: 'UPDATE' as const, + rateID: rate.id, + formData: revision.formData, + } + } + }) + + return { + contractID: contract.id, + updatedRates: rateInputs, + } +} + const createTestDraftRateOnContract = async ( server: ApolloServer, contractID: string, @@ -224,6 +388,9 @@ export { createAndSubmitTestRate, createTestDraftRateOnContract, updateTestDraftRateOnContract, + updateRatesInputFromDraftContract, + addNewRateToTestContract, + addLinkedRateToTestContract, fetchTestRateById, submitTestRate, unlockTestRate, diff --git a/services/app-graphql/src/mutations/submitContract.graphql b/services/app-graphql/src/mutations/submitContract.graphql index 6335083744..aab106020d 100644 --- a/services/app-graphql/src/mutations/submitContract.graphql +++ b/services/app-graphql/src/mutations/submitContract.graphql @@ -68,6 +68,9 @@ fragment rateFields on Rate { fragment rateRevisionFragmentForFetchContract on RateRevision { id + rate { + id + } createdAt updatedAt unlockInfo { diff --git a/services/app-graphql/src/queries/fetchContract.graphql b/services/app-graphql/src/queries/fetchContract.graphql index 7019b1742d..5858943305 100644 --- a/services/app-graphql/src/queries/fetchContract.graphql +++ b/services/app-graphql/src/queries/fetchContract.graphql @@ -67,6 +67,9 @@ fragment rateFields on Rate { fragment rateRevisionFragmentForFetchContract on RateRevision { id + rate { + id + } createdAt updatedAt unlockInfo { diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index a3148f9cea..228eabb98c 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -1280,6 +1280,7 @@ for the rate and contains the full data from when the rate cert was submitted """ type RateRevision { id: ID! + rate: Rate createdAt: DateTime! updatedAt: DateTime! """ From 0cd849c597b9b4f7943d3729ed2ab28dbc7dd36d Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Wed, 3 Apr 2024 12:55:09 -0700 Subject: [PATCH 08/49] fix after linting --- services/app-api/src/resolvers/contract/submitContract.test.ts | 2 +- services/app-api/src/resolvers/contract/submitContract.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts index fe9a02865a..3ab798faa6 100644 --- a/services/app-api/src/resolvers/contract/submitContract.test.ts +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -94,7 +94,7 @@ describe('submitContract', () => { expect(res.errors).toBeDefined() expect(res.errors && res.errors[0].message).toBe( - 'user not authorized to create state data' + 'user not authorized to fetch state data' ) }) }) diff --git a/services/app-api/src/resolvers/contract/submitContract.ts b/services/app-api/src/resolvers/contract/submitContract.ts index 31f5d3630b..4a283e0da4 100644 --- a/services/app-api/src/resolvers/contract/submitContract.ts +++ b/services/app-api/src/resolvers/contract/submitContract.ts @@ -13,12 +13,12 @@ export function submitContract( ): MutationResolvers['submitContract'] { return async (parent, { input }, context) => { // For some reason the types for resolvers are not actually callable? - // eslint-disable-next-line @typescript-eslint/no-explicit-any const submitHPPResolver = submitHealthPlanPackageResolver( store, emailer, emailParameterStore, launchDarkly + // eslint-disable-next-line @typescript-eslint/no-explicit-any ) as any await submitHPPResolver( From a42ea4bce68d56e2ea1f1fd3492e8b27184133b9 Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Thu, 4 Apr 2024 02:28:08 -0700 Subject: [PATCH 09/49] fix some bugs --- .../migration.sql | 7 ++ services/app-api/prisma/schema.prisma | 2 +- .../updateDraftContractRates.ts | 11 ++- .../resolvers/contract/submitContract.test.ts | 83 ++++++++++++++++++- .../contract/updateDraftContractRates.ts | 1 - .../updateDraftContractRates.graphql | 6 ++ 6 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 services/app-api/prisma/migrations/20240404083919_cascade_rate_joins/migration.sql diff --git a/services/app-api/prisma/migrations/20240404083919_cascade_rate_joins/migration.sql b/services/app-api/prisma/migrations/20240404083919_cascade_rate_joins/migration.sql new file mode 100644 index 0000000000..9dde8c8281 --- /dev/null +++ b/services/app-api/prisma/migrations/20240404083919_cascade_rate_joins/migration.sql @@ -0,0 +1,7 @@ +BEGIN; +-- DropForeignKey +ALTER TABLE "DraftRateJoinTable" DROP CONSTRAINT "DraftRateJoinTable_rateID_fkey"; + +-- AddForeignKey +ALTER TABLE "DraftRateJoinTable" ADD CONSTRAINT "DraftRateJoinTable_rateID_fkey" FOREIGN KEY ("rateID") REFERENCES "RateTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; +COMMIT; diff --git a/services/app-api/prisma/schema.prisma b/services/app-api/prisma/schema.prisma index 6d311e9e94..612dc88034 100644 --- a/services/app-api/prisma/schema.prisma +++ b/services/app-api/prisma/schema.prisma @@ -77,7 +77,7 @@ model DraftRateJoinTable { contractID String contract ContractTable @relation(fields: [contractID], references: [id]) rateID String - rate RateTable @relation(fields: [rateID], references: [id]) + rate RateTable @relation(fields: [rateID], references: [id], onDelete: Cascade) ratePosition Int @@id([contractID, rateID]) diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts index e0c0120227..e581039fe0 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts @@ -223,6 +223,13 @@ async function updateDraftContractRates( }, }, }) + await tx.rateTable.deleteMany({ + where: { + id: { + in: args.rateUpdates.delete.map((ru) => ru.rateID), + }, + }, + }) const oldLinksToCreate = [ ...createdRateJoins.map((lr) => lr.rateID), @@ -234,16 +241,12 @@ async function updateDraftContractRates( where: { id: draftRevision.id }, data: { draftRates: { - // create: ratesToCreate, connect: oldLinksToCreate.map((rID) => ({ id: rID, })), disconnect: args.rateUpdates.unlink.map((ru) => ({ id: ru.rateID, })), - delete: args.rateUpdates.delete.map((ru) => ({ - id: ru.rateID, - })), }, }, include: { diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts index 3ab798faa6..baf45c59a7 100644 --- a/services/app-api/src/resolvers/contract/submitContract.test.ts +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -1,10 +1,14 @@ -import { constructTestPostgresServer } from '../../testHelpers/gqlHelpers' +import { + constructTestPostgresServer, + createAndUpdateTestHealthPlanPackage, +} from '../../testHelpers/gqlHelpers' import SUBMIT_CONTRACT from '../../../../app-graphql/src/mutations/submitContract.graphql' import { testCMSUser } from '../../testHelpers/userHelpers' import type { SubmitContractInput } from '../../gen/gqlServer' import { createAndSubmitTestContractWithRate, createAndUpdateTestContractWithoutRates, + fetchTestContract, submitTestContract, } from '../../testHelpers/gqlContractHelpers' import { @@ -47,7 +51,7 @@ describe('submitContract', () => { expect(rate.status).toBe('SUBMITTED') }) - it('handles a submission with multiple connections', async () => { + it('handles a submission with a link', async () => { const stateServer = await constructTestPostgresServer() const contract1 = await createAndSubmitTestContractWithRate(stateServer) @@ -75,6 +79,81 @@ describe('submitContract', () => { expect(sub.rateRevisions).toHaveLength(1) }) + it('calls create twice in a row', async () => { + const stateServer = await constructTestPostgresServer() + + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + await addNewRateToTestContract(stateServer, draftA0) + await addNewRateToTestContract(stateServer, draftA0) + + const final = await fetchTestContract(stateServer, AID) + expect(final.draftRates).toHaveLength(1) + }) + + it('handles the first miro scenario', async () => { + const stateServer = await constructTestPostgresServer() + + // 1. Submit A0 with Rate1 and Rate2 + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + const draftA010 = await addNewRateToTestContract(stateServer, draftA0) + + await addNewRateToTestContract(stateServer, draftA010) + + const contractA0 = await submitTestContract(stateServer, AID) + const subA0 = contractA0.packageSubmissions[0] + const rate10 = subA0.rateRevisions[0] + const OneID = rate10.id + const rate20 = subA0.rateRevisions[1] + const TwoID = rate20.id + + // 2. Submit B0 with Rate1 and Rate3 + const draftB0 = + await createAndUpdateTestContractWithoutRates(stateServer) + await addLinkedRateToTestContract(stateServer, draftB0, OneID) + await addNewRateToTestContract(stateServer, draftB0) + + const contractB0 = await submitTestContract(stateServer, draftB0.id) + const subB0 = contractB0.packageSubmissions[0] + const rate30 = subB0.rateRevisions[1] + const ThreeID = rate30.id + + expect(subB0.rateRevisions[0].id).toBe(OneID) + + // 3. Submit C0 with Rate20 and Rate40 + const draftC0 = + await createAndUpdateTestContractWithoutRates(stateServer) + await addLinkedRateToTestContract(stateServer, draftC0, TwoID) + await addNewRateToTestContract(stateServer, draftC0) + + const contractC0 = await submitTestContract(stateServer, draftC0.id) + const subC0 = contractC0.packageSubmissions[0] + const rate40 = subC0.rateRevisions[1] + const FourID = rate40.id + + expect(subC0.rateRevisions[0].id).toBe(TwoID) + + // 4. Submit D0, contract only + const draftD0 = await createAndUpdateTestHealthPlanPackage( + stateServer, + { + rateInfos: [], + submissionType: 'CONTRACT_ONLY', + addtlActuaryContacts: [], + addtlActuaryCommunicationPreference: undefined, + } + ) + + const contractD0 = await submitTestContract(stateServer, draftD0.id) + + console.info(ThreeID, FourID, contractD0) + + throw new Error('NO Dice') + }) + it('returns an error if a CMS user attempts to call submitContract', async () => { const cmsServer = await constructTestPostgresServer({ context: { diff --git a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts index 353c67ee19..44e19d29d4 100644 --- a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts +++ b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts @@ -142,7 +142,6 @@ function updateDraftContractRates( } // walk through the new rate list one by one - // TODO set the position in the list correctly // any rates that aren't in this list are unlinked (or deleted?) const knownRateIDs = draftRates.map((r) => r.id) let thisPosition = 1 diff --git a/services/app-graphql/src/mutations/updateDraftContractRates.graphql b/services/app-graphql/src/mutations/updateDraftContractRates.graphql index fccab22fc2..6a871ddbed 100644 --- a/services/app-graphql/src/mutations/updateDraftContractRates.graphql +++ b/services/app-graphql/src/mutations/updateDraftContractRates.graphql @@ -78,6 +78,7 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { rateCapitationType rateDateStart rateDateEnd + rateCertificationName rateDateCertified amendmentEffectiveDateStart amendmentEffectiveDateEnd @@ -104,6 +105,11 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { s3URL sha256 } + packagesWithSharedRateCerts { + packageName + packageId + packageStatus + } } } From 1686aa9e1844fc141ce0455f3217e7e17b4afaec Mon Sep 17 00:00:00 2001 From: MacRae Linton Date: Thu, 4 Apr 2024 11:37:52 -0700 Subject: [PATCH 10/49] get setup of scenario working --- .../prismaSubmittedContractHelpers.ts | 3 + .../contractAndRates/submitContract.ts | 12 +- .../resolvers/contract/submitContract.test.ts | 21 ++-- .../contract/updateDraftContractRates.ts | 107 ++++++++++++------ .../src/testHelpers/gqlContractHelpers.ts | 8 +- .../updateDraftContractRates.graphql | 4 + .../app-web/src/common-code/testHelpers.ts | 8 ++ 7 files changed, 111 insertions(+), 52 deletions(-) create mode 100644 services/app-web/src/common-code/testHelpers.ts diff --git a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts index a03e417ae6..4dcbddff3f 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts @@ -54,6 +54,9 @@ const includeFullContract = { include: includeRateFormData, }, }, + orderBy: { + ratePosition: 'asc', + } }, }, }, diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index a5858d4254..649d71f73b 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -232,7 +232,9 @@ export async function submitContract( } } - // previously connected contracts + // get previously connected contracts + // get the related rates, all of their previously connected contracts need to + // get links. const allRelatedRateRevisionsBefore = await tx.rateRevisionTable.findMany({ where: { @@ -258,7 +260,7 @@ export async function submitContract( }, }) - // now that we've fetched their current packages, write them to the newly related table. + // all related rates get an entry in the relatedRates connection await tx.updateInfoTable.update({ where: { id: submitInfo.id, @@ -287,11 +289,11 @@ export async function submitContract( if ( rateRev.relatedSubmissions && - rateRev.relatedSubmissions.length > 0 + rateRev.relatedSubmissions.length > 1 ) { - const latestSub = rateRev.relatedSubmissions[0] + const previousSub = rateRev.relatedSubmissions[1] - for (const contractConnection of latestSub.submissionPackages) { + for (const contractConnection of previousSub.submissionPackages) { if ( contractConnection.contractRevision.contractID !== currentContract.id diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts index baf45c59a7..1243423672 100644 --- a/services/app-api/src/resolvers/contract/submitContract.test.ts +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -106,35 +106,34 @@ describe('submitContract', () => { const contractA0 = await submitTestContract(stateServer, AID) const subA0 = contractA0.packageSubmissions[0] const rate10 = subA0.rateRevisions[0] - const OneID = rate10.id + const OneID = rate10.rate!.id const rate20 = subA0.rateRevisions[1] - const TwoID = rate20.id + const TwoID = rate20.rate!.id // 2. Submit B0 with Rate1 and Rate3 const draftB0 = await createAndUpdateTestContractWithoutRates(stateServer) - await addLinkedRateToTestContract(stateServer, draftB0, OneID) - await addNewRateToTestContract(stateServer, draftB0) + const draftB010 = await addLinkedRateToTestContract(stateServer, draftB0, OneID) + await addNewRateToTestContract(stateServer, draftB010) const contractB0 = await submitTestContract(stateServer, draftB0.id) const subB0 = contractB0.packageSubmissions[0] const rate30 = subB0.rateRevisions[1] - const ThreeID = rate30.id + const ThreeID = rate30.rate!.id - expect(subB0.rateRevisions[0].id).toBe(OneID) + expect(subB0.rateRevisions[0].rate!.id).toBe(OneID) // 3. Submit C0 with Rate20 and Rate40 const draftC0 = await createAndUpdateTestContractWithoutRates(stateServer) - await addLinkedRateToTestContract(stateServer, draftC0, TwoID) - await addNewRateToTestContract(stateServer, draftC0) + const draftC020 = await addLinkedRateToTestContract(stateServer, draftC0, TwoID) + await addNewRateToTestContract(stateServer, draftC020) const contractC0 = await submitTestContract(stateServer, draftC0.id) const subC0 = contractC0.packageSubmissions[0] const rate40 = subC0.rateRevisions[1] - const FourID = rate40.id - - expect(subC0.rateRevisions[0].id).toBe(TwoID) + const FourID = rate40.rate!.id + expect(subC0.rateRevisions[0].rate!.id).toBe(TwoID) // 4. Submit D0, contract only const draftD0 = await createAndUpdateTestHealthPlanPackage( diff --git a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts index 44e19d29d4..5c2de837c6 100644 --- a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts +++ b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts @@ -12,6 +12,7 @@ import { rateFormDataSchema } from '../../domain-models/contractAndRates' import { ForbiddenError, UserInputError } from 'apollo-server-core' import { z } from 'zod' import type { UpdateDraftContractRatesArgsType } from '../../postgres/contractAndRates/updateDraftContractRates' +import { generateRateCertificationName } from '../rate/generateRateCertificationName' // Zod schemas to parse the updatedRates param since the types are not fully defined in GQL // CREATE / UPDATE / LINK @@ -88,6 +89,20 @@ function updateDraftContractRates( }) } + const statePrograms = store.findStatePrograms(contract.stateCode) + if (statePrograms instanceof Error) { + const errMessage = `Couldn't find programs for state ${contract.stateCode}. Message: ${statePrograms.message}` + logError('updateDraftContractRates', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + // AUTHORIZATION // Only callable by a state user from this state if (isStateUser(user)) { @@ -147,8 +162,20 @@ function updateDraftContractRates( let thisPosition = 1 for (const rateUpdate of parsedUpdates) { if (rateUpdate.type === 'CREATE') { + + // set rateName for now https://jiraent.cms.gov/browse/MCR-4012 + const rateName = generateRateCertificationName( + rateUpdate.formData, + contract.stateCode, + contract.stateNumber, + statePrograms, + ) + rateUpdates.create.push({ - formData: rateUpdate.formData, + formData: { + ...rateUpdate.formData, + rateCertificationName: rateName, + }, ratePosition: thisPosition, }) } @@ -212,9 +239,20 @@ function updateDraftContractRates( // } } + // set rateName for now https://jiraent.cms.gov/browse/MCR-4012 + const rateName = generateRateCertificationName( + rateUpdate.formData, + contract.stateCode, + contract.stateNumber, + statePrograms, + ) + rateUpdates.update.push({ rateID: rateUpdate.rateID, - formData: rateUpdate.formData, + formData: { + ...rateUpdate.formData, + rateCertificationName: rateName, + }, ratePosition: thisPosition, }) } @@ -223,45 +261,50 @@ function updateDraftContractRates( const knownRateIDX = knownRateIDs.indexOf(rateUpdate.rateID) if (knownRateIDX !== -1) { knownRateIDs.splice(knownRateIDX, 1) - continue - } + // we still pass all links down to the db, position might have changed? + rateUpdates.link.push({ + rateID: rateUpdate.rateID, + ratePosition: thisPosition, + }) + } else { + + // linked rates must exist and not be DRAFT + const rateToLink = await store.findRateWithHistory( + rateUpdate.rateID + ) + if (rateToLink instanceof Error) { + if (rateToLink instanceof NotFoundError) { + const errmsg = + 'Attempting to link a rate that does not exist: ' + + rateUpdate.rateID + logError('updateDraftContractRates', errmsg) + setErrorAttributesOnActiveSpan(errmsg, span) + throw new UserInputError(errmsg) + } - // linked rates must exist and not be DRAFT - const rateToLink = await store.findRateWithHistory( - rateUpdate.rateID - ) - if (rateToLink instanceof Error) { - if (rateToLink instanceof NotFoundError) { const errmsg = - 'Attempting to link a rate that does not exist: ' + + 'Unexpected Error: couldnt fetch the linking rate: ' + rateUpdate.rateID logError('updateDraftContractRates', errmsg) setErrorAttributesOnActiveSpan(errmsg, span) - throw new UserInputError(errmsg) + throw new Error(errmsg) } - const errmsg = - 'Unexpected Error: couldnt fetch the linking rate: ' + - rateUpdate.rateID - logError('updateDraftContractRates', errmsg) - setErrorAttributesOnActiveSpan(errmsg, span) - throw new Error(errmsg) - } + if (rateToLink.status === 'DRAFT') { + const errmsg = + 'Attempted to link a rate that has never been submitted: ' + + rateUpdate.rateID + logError('updateDraftContractRates', errmsg) + setErrorAttributesOnActiveSpan(errmsg, span) + throw new UserInputError(errmsg) + } - if (rateToLink.status === 'DRAFT') { - const errmsg = - 'Attempted to link a rate that has never been submitted: ' + - rateUpdate.rateID - logError('updateDraftContractRates', errmsg) - setErrorAttributesOnActiveSpan(errmsg, span) - throw new UserInputError(errmsg) + // this is a new link, actually link them. + rateUpdates.link.push({ + rateID: rateUpdate.rateID, + ratePosition: thisPosition, + }) } - - // this is a new link, actually link them. - rateUpdates.link.push({ - rateID: rateUpdate.rateID, - ratePosition: thisPosition, - }) } thisPosition++ diff --git a/services/app-api/src/testHelpers/gqlContractHelpers.ts b/services/app-api/src/testHelpers/gqlContractHelpers.ts index cd52e8d0d0..4d9d458710 100644 --- a/services/app-api/src/testHelpers/gqlContractHelpers.ts +++ b/services/app-api/src/testHelpers/gqlContractHelpers.ts @@ -66,12 +66,12 @@ async function submitTestContract( if (result.errors) { throw new Error( - `fetchTestRateById query failed with errors ${result.errors}` + `submitTestContract query failed with errors ${result.errors}` ) } if (!result.data) { - throw new Error('fetchTestRateById returned nothing') + throw new Error('submitTestContract returned nothing') } return result.data.submitContract.contract @@ -97,12 +97,12 @@ async function fetchTestContract( if (result.errors) { throw new Error( - `fetchTestRateById query failed with errors ${result.errors}` + `fetchTestContract query failed with errors ${result.errors}` ) } if (!result.data) { - throw new Error('fetchTestRateById returned nothing') + throw new Error('fetchTestContract returned nothing') } return result.data.fetchContract.contract diff --git a/services/app-graphql/src/mutations/updateDraftContractRates.graphql b/services/app-graphql/src/mutations/updateDraftContractRates.graphql index 6a871ddbed..9565622290 100644 --- a/services/app-graphql/src/mutations/updateDraftContractRates.graphql +++ b/services/app-graphql/src/mutations/updateDraftContractRates.graphql @@ -89,11 +89,13 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { name email titleRole + actuarialFirm } addtlActuaryContacts { name email titleRole + actuarialFirm } rateDocuments { name @@ -138,11 +140,13 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { name email titleRole + actuarialFirm } addtlActuaryContacts { name email titleRole + actuarialFirm } rateDocuments { name diff --git a/services/app-web/src/common-code/testHelpers.ts b/services/app-web/src/common-code/testHelpers.ts new file mode 100644 index 0000000000..6b99763bf5 --- /dev/null +++ b/services/app-web/src/common-code/testHelpers.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function jsonStringify(obj: any): string { + return JSON.stringify(obj, null, ' ') +} + +export { + jsonStringify +} From 8201ea036c07413306fe9665750ac2906f2c030c Mon Sep 17 00:00:00 2001 From: pearl-truss Date: Thu, 4 Apr 2024 15:56:16 -0400 Subject: [PATCH 11/49] Wip with handling submit --- .../contractAndRates/formDataTypes.ts | 2 +- .../contractAndRates/submitContract.ts | 29 +- .../src/resolvers/configureResolvers.ts | 2 + .../src/resolvers/contract/submitContract.ts | 182 +++++++++++ .../src/mutations/submitContract.graphql | 298 ++++++++++++++++++ services/app-graphql/src/schema.graphql | 143 +++++++++ .../mutationWrappersForUserFriendlyErrors.ts | 51 +++ .../V2/ReviewSubmit/ReviewSubmitV2.tsx | 14 +- .../V2/ReviewSubmit/UnlockSubmitModalV2.tsx | 224 +++++++++++++ .../StateSubmission/StateSubmissionForm.tsx | 5 +- .../V2/ContractDetailsSummarySectionV2.tsx | 7 +- .../V2/SubmissionSummaryV2.tsx | 42 +-- 12 files changed, 954 insertions(+), 45 deletions(-) create mode 100644 services/app-api/src/resolvers/contract/submitContract.ts create mode 100644 services/app-graphql/src/mutations/submitContract.graphql create mode 100644 services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/UnlockSubmitModalV2.tsx diff --git a/services/app-api/src/domain-models/contractAndRates/formDataTypes.ts b/services/app-api/src/domain-models/contractAndRates/formDataTypes.ts index 3467b2bbb9..41e07efe33 100644 --- a/services/app-api/src/domain-models/contractAndRates/formDataTypes.ts +++ b/services/app-api/src/domain-models/contractAndRates/formDataTypes.ts @@ -40,7 +40,7 @@ const contractFormDataSchema = z.object({ submissionDescription: z.string(), stateContacts: z.array(stateContactSchema), supportingDocuments: z.array(documentSchema), - contractType: contractTypeSchema, + contractType: contractTypeSchema.optional(), contractExecutionStatus: contractExecutionStatusSchema.optional(), contractDocuments: z.array(documentSchema), contractDateStart: z.date().optional(), diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index 583014136d..e1bdc064af 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -4,9 +4,12 @@ import { findContractWithHistory } from './findContractWithHistory' import { NotFoundError } from '../postgresErrors' import type { UpdateInfoType } from '../../domain-models' import { includeDraftRates } from './prismaDraftContractHelpers' +import type { ContractFormDataType } from '../../domain-models' export type SubmitContractArgsType = { contractID: string // revision ID + contractRevisionID?: string // this is a hack that should not outlive protobuf. rateID should be there and be required after we remove protos + formData?: ContractFormDataType submittedByUserID: UpdateInfoType['updatedBy'] submittedReason: UpdateInfoType['updatedReason'] } @@ -17,17 +20,29 @@ export async function submitContract( client: PrismaClient, args: SubmitContractArgsType ): Promise { - const { contractID, submittedByUserID, submittedReason } = args + const { contractID, submittedByUserID, contractRevisionID, submittedReason } = args const currentDateTime = new Date() - + console.log('in postgress', args) try { return await client.$transaction(async (tx) => { + if (!contractID && !contractRevisionID) { + return new Error( + 'Either contractID or contractRevisionID must be supplied. both are blank' + ) + } // find the current contract with related rates + const findWhere = contractRevisionID + ? { + id: contractRevisionID, + submitInfoID: null, + } + : { + contractID, + submitInfoID: null, + } + console.log(findWhere, 'findwhere') const currentRev = await client.contractRevisionTable.findFirst({ - where: { - contractID: contractID, - submitInfoID: null, - }, + where: findWhere, include: { draftRates: { include: includeDraftRates, @@ -38,6 +53,7 @@ export async function submitContract( if (!currentRev) { const err = `PRISMA ERROR: Cannot find the current rev to submit with contract id: ${contractID}` console.error(err) + console.log(err, 'err') return new NotFoundError(err) } @@ -171,6 +187,7 @@ export async function submitContract( return await findContractWithHistory(tx, contractID) }) } catch (err) { + console.log('ultimate error', err) const error = new Error(`Error submitting contract ${err}`) return error } diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 11a5e5ad72..9d061c4d8f 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -31,6 +31,7 @@ import { indexRatesResolver } from './rate/indexRates' import { rateResolver } from './rate/rateResolver' import { fetchRateResolver } from './rate/fetchRate' import { updateContract } from './contract/updateContract' +import { submitContract } from './contract/submitContract' import { createAPIKeyResolver } from './APIKey' import { unlockRate } from './rate/unlockRate' import { submitRate } from './rate/submitRate' @@ -97,6 +98,7 @@ export function configureResolvers( createAPIKey: createAPIKeyResolver(jwt), unlockRate: unlockRate(store), submitRate: submitRate(store, launchDarkly), + submitContract: submitContract(store, launchDarkly), }, User: { // resolveType is required to differentiate Unions diff --git a/services/app-api/src/resolvers/contract/submitContract.ts b/services/app-api/src/resolvers/contract/submitContract.ts new file mode 100644 index 0000000000..46e324837b --- /dev/null +++ b/services/app-api/src/resolvers/contract/submitContract.ts @@ -0,0 +1,182 @@ +import type { Store } from '../../postgres' +import type { MutationResolvers } from '../../gen/gqlServer' +import { + setErrorAttributesOnActiveSpan, + setResolverDetailsOnActiveSpan, +} from '../attributeHelper' +import { isStateUser } from '../../domain-models' +import { logError } from '../../logger' +import { ForbiddenError, UserInputError } from 'apollo-server-lambda' +import { NotFoundError } from '../../postgres' +import { GraphQLError } from 'graphql/index' +import type { LDService } from '../../launchDarkly/launchDarkly' +import { generateRateCertificationName } from '../rate/generateRateCertificationName' +import { findStatePrograms } from '../../../../app-web/src/common-code/healthPlanFormDataType/findStatePrograms' +import { nullsToUndefined } from '../../domain-models/nullstoUndefined' + +/* + Submit rate will change a draft revision to submitted and generate a rate name if one is missing + Also, if form data is passed in (such as on standalone rate edits) the form data itself will be updated +*/ +export function submitContract( + store: Store, + launchDarkly: LDService +): MutationResolvers['submitContract'] { + return async (_parent, { input }, context) => { + console.log('in resolver') + const { user, span } = context + const { contractID, submittedReason, formData } = input + const featureFlags = await launchDarkly.allFlags(context) + setResolverDetailsOnActiveSpan('submitContract', user, span) + span?.setAttribute('mcreview.contract_id', contractID) + + // throw error if the feature flag is off + if (!featureFlags?.['rate-edit-unlock']) { + const errMessage = `Not authorized to edit and submit a rate independently, the feature is disabled` + logError('submitContract', errMessage) + throw new ForbiddenError(errMessage, { + message: errMessage, + }) + } + + // This resolver is only callable by State users + if (!isStateUser(user)) { + const errMessage = 'user not authorized to submit rate' + logError('submitContract', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new ForbiddenError(errMessage) + } + + // find the contract to submit + const unsubmittedContract = await store.findContractWithHistory(contractID) + console.log(unsubmittedContract, 'contract to submit') + if (unsubmittedContract instanceof Error) { + if (unsubmittedContract instanceof NotFoundError) { + const errMessage = `A rate must exist to be submitted: ${contractID}` + logError('submitContract', errMessage) + console.log(errMessage, 'errMessage') + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage, { + argumentName: 'contractID', + }) + } + + logError('submitContract', unsubmittedContract.message) + setErrorAttributesOnActiveSpan(unsubmittedContract.message, span) + throw new GraphQLError(unsubmittedContract.message, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + // const draftRevision = unsubmittedContract.draftRevision + + // if (!draftRevision) { + // throw new Error( + // 'PROGRAMMING ERROR: Status should not be submittable without a draft rate revision' + // ) + // } + + // make sure it is draft or unlocked + if ( + unsubmittedContract.status === 'SUBMITTED' || + unsubmittedContract.status === 'RESUBMITTED' + ) { + const errMessage = `Attempted to submit a rate that is already submitted` + logError('submitContract', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage, { + argumentName: 'contractID', + cause: 'INVALID_PACKAGE_STATUS', + }) + } + + // prepare to generate rate cert name - either use new form data coming down on submit or unsubmitted submission data already in database + const stateCode = unsubmittedContract.stateCode + const stateNumber = unsubmittedContract.stateNumber + const statePrograms = findStatePrograms(stateCode) + // const generatedRateCertName = formData + // ? generateRateCertificationName( + // nullsToUndefined(formData), + // stateCode, + // stateNumber, + // statePrograms + // ) + // : generateRateCertificationName( + // draftRevision.formData, + // stateCode, + // stateNumber, + // statePrograms + // ) + + // combine existing db draft data with any form data added on submit + // call submit contract handler + const submittedContract = await store.submitContract({ + contractID, + submittedByUserID: user.id, + submittedReason: submittedReason ?? 'Initial submission', + formData: formData + ? { + contractType: formData.contractType ?? undefined, + programIDs: formData.programIDs ?? undefined, + populationCovered: formData.populationCovered ?? undefined, + submissionType: formData.submissionType ?? undefined, + riskBasedContract: formData.riskBasedContract ?? undefined, + submissionDescription: formData.submissionDescription ?? undefined, + stateContacts: [ + { + name: 'Test Person', + titleRole: 'A Role', + email: 'test+state+contact@example.com', + }, + ], + supportingDocuments: formData.supportingDocuments ?? undefined, + contractExecutionStatus: formData.contractExecutionStatus ?? undefined, + contractDocuments: formData.contractDocuments ?? undefined, + contractDateStart: formData.contractDateStart ?? undefined, + contractDateEnd: formData.contractDateEnd ?? undefined, + managedCareEntities: formData.managedCareEntities ?? undefined, + federalAuthorities: formData.federalAuthorities ?? undefined, + inLieuServicesAndSettings: formData.inLieuServicesAndSettings ?? undefined, + modifiedBenefitsProvided: formData.modifiedBenefitsProvided ?? undefined, + modifiedGeoAreaServed: formData.modifiedGeoAreaServed ?? undefined, + modifiedMedicaidBeneficiaries: formData.modifiedMedicaidBeneficiaries ?? undefined, + modifiedRiskSharingStrategy: formData.modifiedRiskSharingStrategy ?? undefined, + modifiedIncentiveArrangements: formData.modifiedIncentiveArrangements ?? undefined, + modifiedWitholdAgreements: formData.modifiedWitholdAgreements ?? undefined, + modifiedStateDirectedPayments: formData.modifiedStateDirectedPayments ?? undefined, + modifiedPassThroughPayments: formData.modifiedPassThroughPayments ?? undefined, + modifiedPaymentsForMentalDiseaseInstitutions: formData.modifiedPaymentsForMentalDiseaseInstitutions ?? undefined, + modifiedMedicalLossRatioStandards: formData.modifiedMedicalLossRatioStandards ?? undefined, + modifiedOtherFinancialPaymentIncentive: formData.modifiedOtherFinancialPaymentIncentive ?? undefined, + modifiedEnrollmentProcess: formData.modifiedEnrollmentProcess ?? undefined, + modifiedGrevienceAndAppeal: formData.modifiedGrevienceAndAppeal ?? undefined, + modifiedNetworkAdequacyStandards: formData.modifiedNetworkAdequacyStandards ?? undefined, + modifiedLengthOfContract: formData.modifiedLengthOfContract ?? undefined, + modifiedNonRiskPaymentArrangements: formData.modifiedNonRiskPaymentArrangements ?? undefined, + statutoryRegulatoryAttestation: formData.statutoryRegulatoryAttestation ?? undefined, + statutoryRegulatoryAttestationDescription: formData.statutoryRegulatoryAttestationDescription ?? undefined, + } : undefined, + }) + + console.log(submittedContract, 'submitted contract in resolver') + + if (submittedContract instanceof Error) { + const errMessage = `Failed to submit rate with ID: ${contractID}; ${submittedContract.message}` + logError('submitContract', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + return { + contract: submittedContract, + } + } +} diff --git a/services/app-graphql/src/mutations/submitContract.graphql b/services/app-graphql/src/mutations/submitContract.graphql new file mode 100644 index 0000000000..2393a3b90a --- /dev/null +++ b/services/app-graphql/src/mutations/submitContract.graphql @@ -0,0 +1,298 @@ +mutation submitContract($input: SubmitContractInput!) { + submitContract(input: $input) { + contract { + ...contractFieldsForSubmitContract + + draftRevision { + ...contractRevisionFragmentForSubmitContract + } + + draftRates { + ...rateFieldsForSubmitContract + + draftRevision { + ...rateRevisionFragmentForSubmitContract + } + + revisions { + ...rateRevisionFragmentForSubmitContract + } + } + + packageSubmissions { + ...packageSubmissionsFragmentForSubmitContract + } + } + } +} + +fragment contractFieldsForSubmitContract on Contract { + id + status + createdAt + updatedAt + initiallySubmittedAt + stateCode + state { + code + name + programs { + id + name + fullName + } + } + stateNumber +} + +fragment rateFieldsForSubmitContract on Rate { + id + createdAt + updatedAt + stateCode + stateNumber + state { + code + name + programs { + id + name + fullName + } + } + status + initiallySubmittedAt +} + +fragment rateRevisionFragmentForSubmitContract on RateRevision { + id + createdAt + updatedAt + unlockInfo { + updatedAt + updatedBy + updatedReason + } + submitInfo { + updatedAt + updatedBy + updatedReason + } + formData { + rateType + rateCapitationType + rateDocuments { + name + s3URL + sha256 + } + supportingDocuments { + name + s3URL + sha256 + } + rateDateStart + rateDateEnd + rateDateCertified + amendmentEffectiveDateStart + amendmentEffectiveDateEnd + rateProgramIDs + rateCertificationName + certifyingActuaryContacts { + id + name + titleRole + email + actuarialFirm + actuarialFirmOther + } + addtlActuaryContacts { + id + name + titleRole + email + actuarialFirm + actuarialFirmOther + } + actuaryCommunicationPreference + packagesWithSharedRateCerts { + packageName + packageId + packageStatus + } + } + contractRevisions { + id + contract { + id + stateCode + stateNumber + } + createdAt + updatedAt + submitInfo { + updatedAt + updatedBy + updatedReason + } + unlockInfo { + updatedAt + updatedBy + updatedReason + } + formData { + programIDs + populationCovered + submissionType + riskBasedContract + submissionDescription + stateContacts { + name + titleRole + email + } + supportingDocuments { + name + s3URL + sha256 + } + contractType + contractExecutionStatus + contractDocuments { + name + s3URL + sha256 + } + contractDateStart + contractDateEnd + managedCareEntities + federalAuthorities + inLieuServicesAndSettings + modifiedBenefitsProvided + modifiedGeoAreaServed + modifiedMedicaidBeneficiaries + modifiedRiskSharingStrategy + modifiedIncentiveArrangements + modifiedWitholdAgreements + modifiedStateDirectedPayments + modifiedPassThroughPayments + modifiedPaymentsForMentalDiseaseInstitutions + modifiedMedicalLossRatioStandards + modifiedOtherFinancialPaymentIncentive + modifiedEnrollmentProcess + modifiedGrevienceAndAppeal + modifiedNetworkAdequacyStandards + modifiedLengthOfContract + modifiedNonRiskPaymentArrangements + } + } +} + +fragment contractFormDataFragmentForSubmitContract on ContractFormData { + programIDs + + populationCovered + submissionType + + riskBasedContract + submissionDescription + + stateContacts { + name + titleRole + email + } + + supportingDocuments { + name + s3URL + sha256 + } + + contractType + contractExecutionStatus + contractDocuments { + name + s3URL + sha256 + } + + contractDateStart + contractDateEnd + managedCareEntities + federalAuthorities + inLieuServicesAndSettings + modifiedBenefitsProvided + modifiedGeoAreaServed + modifiedMedicaidBeneficiaries + modifiedRiskSharingStrategy + modifiedIncentiveArrangements + modifiedWitholdAgreements + modifiedStateDirectedPayments + modifiedPassThroughPayments + modifiedPaymentsForMentalDiseaseInstitutions + modifiedMedicaidBeneficiaries + modifiedMedicalLossRatioStandards + modifiedOtherFinancialPaymentIncentive + modifiedEnrollmentProcess + modifiedGrevienceAndAppeal + modifiedNetworkAdequacyStandards + modifiedLengthOfContract + modifiedNonRiskPaymentArrangements +} + +fragment contractRevisionFragmentForSubmitContract on ContractRevision { + id + createdAt + updatedAt + contractName + + submitInfo { + updatedAt + updatedBy + updatedReason + } + + unlockInfo { + updatedAt + updatedBy + updatedReason + } + + formData { + ...contractFormDataFragmentForSubmitContract + } +} + +fragment packageSubmissionsFragmentForSubmitContract on ContractPackageSubmission { + cause + submitInfo { + ...updateInformationFieldsForSubmitContract + } + + submittedRevisions { + ...submittableRevisionsFieldsForSubmitContract + } + + contractRevision { + ...contractRevisionFragmentForSubmitContract + } + rateRevisions { + ...rateRevisionFragmentForSubmitContract + } +} + +fragment submittableRevisionsFieldsForSubmitContract on SubmittableRevision { + ... on ContractRevision { + ...contractRevisionFragmentForSubmitContract + } + ... on RateRevision { + ...rateRevisionFragmentForSubmitContract + } +} + +fragment updateInformationFieldsForSubmitContract on UpdateInformation { + updatedAt + updatedBy + updatedReason +} diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index 1d177e6377..5615a9a358 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -348,6 +348,10 @@ type Mutation { - Attempted to unlock a rate in the DRAFT or UNLOCKED state """ submitRate(input: SubmitRateInput!): SubmitRatePayload! + """ + test + """ + submitContract(input: SubmitContractInput!): SubmitContractPayload! } input CreateHealthPlanPackageInput { @@ -880,6 +884,133 @@ type StateContact { email: String } +"Contact information for contacting states regarding their submission" +input StateContactInput { + name: String + titleRole: String + email: String +} + +""" +ContractFormData represents the form data that was inputted by the state +This type is used for the form data field found on a contract revision +""" +input ContractFormDataInput { + """ + An array of IDs representing state programs that the contract covers + """ + programIDs: [String!]! + """ + The large overarching population of people that the program covers. + Options are MEDICAID, CHIP, MEDICAID_AND_CHIP + """ + populationCovered: PopulationCovered + """ + The submission type of this package + Options are CONTRACT_ONLY and CONTRACT_AND_RATES + """ + submissionType: SubmissionType! + """ + Whether or not this contract is risk based + Risk-based contracts have specific requirements that + non-risk based contracts do not have + """ + riskBasedContract: Boolean + "State provided summary of the contract being submitted" + submissionDescription: String! + """ + Array of state contacts of state representatives who should be + contacted about updates to the contract + Each state contact contains string fields for: name, title, and email + """ + stateContacts: [StateContactInput!]! + """ + Additional documents the state uploads to support a contract + Files can be PDF, DOC, DOCX, XLSX, CSV format + """ + supportingDocuments: [GenericDocumentInput!]! + """ + Type of contract the state is submitting + Options are: BASE, AMENDMENT + """ + contractType: ContractType + """ + Execution status for a contract. + Contracts are fully executed or unexecuted by some or all parties + Status can be either EXECUTED or UNEXECUTED + """ + contractExecutionStatus: ContractExecutionStatus + """ + State upload of the submitted contract + """ + contractDocuments: [GenericDocumentInput!]! + "Start date of the contract" + contractDateStart: Date + "End date of the contract" + contractDateEnd: Date + """ + The type of organization the state is contracting with + in order to deliver managed care services + Options are MCO, PIHP, PAHP, and PCCM + """ + managedCareEntities: [ManagedCareEntity!]! + """ + The state plan and/or waiver authorities that allow the state + to run its managed care programs + """ + federalAuthorities: [FederalAuthority!]! + """ + If contract is in Lieu-of Services and Settings (ILOSs) + in accordance with 42 CFR § 438.3(e)(2) + """ + inLieuServicesAndSettings: Boolean + "If contract includes modifications to benefits provided by the managed care plans" + modifiedBenefitsProvided: Boolean + "If contract includes modifications to the geographic areas served by the managed care plans" + modifiedGeoAreaServed: Boolean + """ + If contract includes modifications to the Medicaid beneficiaries served by the managed care + plans (e.g. eligibility or enrollment criteria) + """ + modifiedMedicaidBeneficiaries: Boolean + "If contract includes modifications to the risk sharing strategy" + modifiedRiskSharingStrategy: Boolean + "If contract includes modifications to incentive arrangements" + modifiedIncentiveArrangements: Boolean + "If contract includes modifications to the withold agreements" + modifiedWitholdAgreements: Boolean + "If contract includes modifications to the state directed payments" + modifiedStateDirectedPayments: Boolean + "If contract includes modifications to the pass-through payments" + modifiedPassThroughPayments: Boolean + """ + If contract includes modifications to payments to MCOs and PIHPs for enrollees that + are a patient in an institution for mental disease + """ + modifiedPaymentsForMentalDiseaseInstitutions: Boolean + "If contract includes modifications to the medical loss ratio standards" + modifiedMedicalLossRatioStandards: Boolean + """ + If contract includes modifications to + other financial, payment, incentive or related contractual provisions + """ + modifiedOtherFinancialPaymentIncentive: Boolean + "If contract includes modifications to the enrollment/disenrollment process" + modifiedEnrollmentProcess: Boolean + "If contract includes modifications to the grevience and appeal system" + modifiedGrevienceAndAppeal: Boolean + "If contract includes modifications to the network adequacy standards" + modifiedNetworkAdequacyStandards: Boolean + "If contract includes modifications to the length of the contract period" + modifiedLengthOfContract: Boolean + "If contract includes modifications to the non-risk payment arrangements" + modifiedNonRiskPaymentArrangements: Boolean, + "If contract has statutory regulatory attestation" + statutoryRegulatoryAttestation: Boolean, + "Description provided for if contract has statutory regulatory attestation" + statutoryRegulatoryAttestationDescription: String +} + """ ContractFormData represents the form data that was inputted by the state This type is used for the form data field found on a contract revision @@ -1203,6 +1334,18 @@ type UpdateDraftContractRatesPayload { contract: Contract! } +input SubmitContractInput { + contractID: ID! + "User given submission description" + submittedReason: String + "Contract related form data to be updated with submission" + formData: ContractFormDataInput +} + +type SubmitContractPayload { + contract: Contract! +} + type ContractOnRevisionType { id: String! "The two letter abbreviation for the state the contract covers" diff --git a/services/app-web/src/gqlHelpers/mutationWrappersForUserFriendlyErrors.ts b/services/app-web/src/gqlHelpers/mutationWrappersForUserFriendlyErrors.ts index f47dc12db7..7053f83466 100644 --- a/services/app-web/src/gqlHelpers/mutationWrappersForUserFriendlyErrors.ts +++ b/services/app-web/src/gqlHelpers/mutationWrappersForUserFriendlyErrors.ts @@ -13,6 +13,9 @@ import { Division, CreateQuestionInput, CreateQuestionResponseInput, + SubmitContractMutationFn, + Contract, + ContractFormData, } from '../gen/gqlClient' import { ApolloError, GraphQLErrors } from '@apollo/client/errors' @@ -154,6 +157,54 @@ export const submitMutationWrapper = async ( } } +export const submitMutationWrapperV2 = async ( + submitDraftSubmission: SubmitContractMutationFn, + id: string, + submittedReason?: string, + formData?: ContractFormData +): Promise | GraphQLErrors | Error> => { + const input = { + contractID: id, + } + + if (submittedReason) { + Object.assign(input, { + submittedReason, + }) + } + + if (formData) { + Object.assign(input, { + formData, + }) + } + console.log(input, 'input') + try { + console.log('before try') + const { data } = await submitDraftSubmission({ + variables: { + input, + }, + }) + console.log(data, 'data') + if (data?.submitContract.contract) { + return data.submitContract.contract + } else { + console.log('else') + recordJSException( + `[UNEXPECTED]: Error attempting to submit, no data present but returning 200.` + ) + return new Error(ERROR_MESSAGES.submit_error_generic) + } + } catch (error) { + console.log('error') + return handleApolloErrorsAndAddUserFacingMessages( + error, + 'SUBMIT_HEALTH_PLAN_PACKAGE' + ) + } +} + /** * Manually updating the cache for Q&A mutations because the Q&A page is in a layout route that is not unmounted during the Q&A * workflow. So, when calling Q&A mutations the Q&A page will not refetch the data. The alternative would be to use diff --git a/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2.tsx b/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2.tsx index ad6edddcb2..323d7482d9 100644 --- a/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2.tsx +++ b/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2.tsx @@ -27,6 +27,7 @@ import { Loading } from '../../../../../components' import { PageBannerAlerts } from '../../../PageBannerAlerts' import { packageName } from '../../../../../common-code/healthPlanFormDataType' import { usePage } from '../../../../../contexts/PageContext' +import { UnlockSubmitModalV2 } from './UnlockSubmitModalV2' type RouteParams = { id: string @@ -34,11 +35,11 @@ type RouteParams = { export const ReviewSubmitV2 = (): React.ReactElement => { const navigate = useNavigate() const modalRef = useRef(null) - const [isSubmitting] = useState(false) // pull the programs off the user const statePrograms = useStatePrograms() const { loggedInUser } = useAuth() const { updateHeading } = usePage() + const [isSubmitting, setIsSubmitting] = useState(false) const { id } = useParams() if (!id) { @@ -180,14 +181,15 @@ export const ReviewSubmitV2 = (): React.ReactElement => { - {/* // if the session is expiring, close this modal so the countdown modal can appear - */} + /> + )} ) diff --git a/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/UnlockSubmitModalV2.tsx b/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/UnlockSubmitModalV2.tsx new file mode 100644 index 0000000000..963617a3b8 --- /dev/null +++ b/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/UnlockSubmitModalV2.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useState } from 'react' +import { FormGroup, ModalRef, Textarea } from '@trussworks/react-uswds' +import { useNavigate } from 'react-router-dom' +import { + Contract, + useSubmitContractMutation, +} from '../../../../../gen/gqlClient' +import { useFormik } from 'formik' +import { usePrevious } from '../../../../../hooks/usePrevious' +import { Modal } from '../../../../../components/Modal' +import { PoliteErrorMessage } from '../../../../../components/PoliteErrorMessage' +import * as Yup from 'yup' +import styles from '../../../../../components/Modal/UnlockSubmitModal.module.scss' +import { GenericApiErrorProps } from '../../../../../components/Banner/GenericApiErrorBanner/GenericApiErrorBanner' +import { ERROR_MESSAGES } from '../../../../../constants/errors' +import { submitMutationWrapperV2 } from '../../../../../gqlHelpers/mutationWrappersForUserFriendlyErrors' + +type ModalType = 'SUBMIT' | 'RESUBMIT' | 'UNLOCK' + +type ModalValueType = { + modalHeading?: string + onSubmitText?: string + modalDescription?: string + inputHint?: string + unlockSubmitModalInputValidation?: string + errorHeading: string + errorSuggestion?: string +} + +const modalValueDictionary: { [Property in ModalType]: ModalValueType } = { + RESUBMIT: { + modalHeading: 'Summarize changes', + onSubmitText: 'Resubmit', + modalDescription: + 'Once you submit, this package will be sent to CMS for review and you will no longer be able to make changes.', + inputHint: 'Provide summary of all changes made to this submission', + unlockSubmitModalInputValidation: + 'You must provide a summary of changes', + errorHeading: ERROR_MESSAGES.resubmit_error_heading, + }, + UNLOCK: { + modalHeading: 'Reason for unlocking submission', + onSubmitText: 'Unlock', + inputHint: 'Provide reason for unlocking', + unlockSubmitModalInputValidation: + 'You must provide a reason for unlocking this submission', + errorHeading: ERROR_MESSAGES.unlock_error_heading, + }, + SUBMIT: { + modalHeading: 'Ready to submit?', + onSubmitText: 'Submit', + modalDescription: + 'Submitting this package will send it to CMS to begin their review.', + errorHeading: ERROR_MESSAGES.submit_error_heading, + errorSuggestion: ERROR_MESSAGES.submit_error_suggestion, + }, +} + +export const UnlockSubmitModalV2 = ({ + contract, + submissionName, + modalType, + modalRef, + setIsSubmitting, +}: { + contract: Contract + submissionName?: string + modalType: ModalType + modalRef: React.RefObject + setIsSubmitting?: React.Dispatch> +}): React.ReactElement => { + const [focusErrorsInModal, setFocusErrorsInModal] = useState(true) + const [modalAlert, setModalAlert] = useState< + GenericApiErrorProps | undefined + >(undefined) // when api errors error + const navigate = useNavigate() + const modalValues: ModalValueType = modalValueDictionary[modalType] + + const modalFormInitialValues = { + unlockSubmitModalInput: '', + } + + const [submitContract, { loading: submitMutationLoading }] = + useSubmitContractMutation() + + const formik = useFormik({ + initialValues: modalFormInitialValues, + validationSchema: Yup.object().shape({ + unlockSubmitModalInput: Yup.string().defined( + modalValues.unlockSubmitModalInputValidation + ), + }), + onSubmit: (values) => onSubmit(values.unlockSubmitModalInput), + }) + + // TODO check loading state for unlock + const mutationLoading = submitMutationLoading + const isSubmitting = mutationLoading || formik.isSubmitting + const includesFormInput = modalType === 'UNLOCK' || modalType === 'RESUBMIT' + + const prevSubmitting = usePrevious(isSubmitting) + + const submitHandler = async () => { + setFocusErrorsInModal(true) + if (includesFormInput) { + formik.handleSubmit() + } else { + await onSubmit() + } + } + + const onSubmit = async (unlockSubmitModalInput?: string): Promise => { + // TODO handle unlock + const result = await submitMutationWrapperV2( + submitContract, + contract.id, + unlockSubmitModalInput, + contract.draftRevision?.formData + ) + + console.log(result, 'result') + + //Allow submitting/unlocking to continue on EMAIL_ERROR. + if (result instanceof Error && result.cause === 'EMAIL_ERROR') { + modalRef.current?.toggleModal(undefined, false) + + if (modalType !== 'UNLOCK' && submissionName) { + navigate( + `/dashboard/submissions?justSubmitted=${submissionName}` + ) + } // TODO handle unlock case + } else if (result instanceof Error) { + setModalAlert({ + heading: modalValues.errorHeading, + message: result.message, + // When we have generic/unknown errors override any suggestions and display the fallback "please refresh text" + suggestion: + result.message === ERROR_MESSAGES.submit_error_generic || + result.message === ERROR_MESSAGES.unlock_error_generic + ? undefined + : modalValues.errorSuggestion, + }) + } else { + modalRef.current?.toggleModal(undefined, false) + if (modalType !== 'UNLOCK' && submissionName) { + navigate( + `/dashboard/submissions?justSubmitted=${submissionName}` + ) + } + } + } + + // Focus submittedReason field in submission modal on Resubmit click when errors exist + useEffect(() => { + if (focusErrorsInModal && formik.errors.unlockSubmitModalInput) { + const fieldElement: HTMLElement | null = document.querySelector( + `[name="unlockSubmitModalInput"]` + ) + if (fieldElement) { + fieldElement.focus() + setFocusErrorsInModal(false) + } else { + console.info('Attempting to focus element that does not exist') + } + } + }, [focusErrorsInModal, formik.errors]) + + useEffect(() => { + if ( + prevSubmitting !== isSubmitting && + prevSubmitting !== undefined && + setIsSubmitting + ) { + setIsSubmitting(isSubmitting) + } + }, [isSubmitting, setIsSubmitting, prevSubmitting]) + + return ( + + {includesFormInput ? ( +
+ {modalValues.modalDescription && ( +

{modalValues.modalDescription}

+ )} + + {formik.errors.unlockSubmitModalInput && ( + + {formik.errors.unlockSubmitModalInput} + + )} + {modalValues.inputHint && ( + + {modalValues.inputHint} + + )} +