From ad63ba7b2a4bd533f232db4265aeaac5febcb2ae Mon Sep 17 00:00:00 2001 From: Jason Lin Date: Tue, 23 Jan 2024 15:20:05 -0500 Subject: [PATCH 1/8] Add submitRate mutation to the schema and resolver. --- .../postgres/contractAndRates/submitRate.ts | 27 +- .../contractAndRates/updateDraftRate.ts | 127 +++++---- .../src/resolvers/configureResolvers.ts | 2 + services/app-api/src/resolvers/rate/index.ts | 1 + .../src/resolvers/rate/submitRate.test.ts | 256 ++++++++++++++++++ .../app-api/src/resolvers/rate/submitRate.ts | 153 +++++++++++ .../src/mutations/submitRate.graphql | 143 ++++++++++ services/app-graphql/src/schema.graphql | 155 ++++++++++- 8 files changed, 808 insertions(+), 56 deletions(-) create mode 100644 services/app-api/src/resolvers/rate/submitRate.test.ts create mode 100644 services/app-api/src/resolvers/rate/submitRate.ts create mode 100644 services/app-graphql/src/mutations/submitRate.graphql diff --git a/services/app-api/src/postgres/contractAndRates/submitRate.ts b/services/app-api/src/postgres/contractAndRates/submitRate.ts index 9e68dcb957..9cda2932c4 100644 --- a/services/app-api/src/postgres/contractAndRates/submitRate.ts +++ b/services/app-api/src/postgres/contractAndRates/submitRate.ts @@ -1,15 +1,18 @@ import { findRateWithHistory } from './findRateWithHistory' +import { updateDraftRate } from './updateDraftRate' import type { UpdateInfoType } from '../../domain-models' import type { PrismaClient } from '@prisma/client' import type { RateType } from '../../domain-models/contractAndRates' import { includeLatestSubmittedRateRev } from './prismaSubmittedContractHelpers' import { NotFoundError } from '../postgresErrors' +import type { RateFormDataType } from '../../domain-models' type SubmitRateArgsType = { rateID?: string rateRevisionID?: string // this is a hack that should not outlive protobuf. Protobufs only have rate revision IDs submittedByUserID: UpdateInfoType['updatedBy'] submitReason: UpdateInfoType['updatedReason'] + formData?: RateFormDataType } // Update the given revision // * invalidate relationships of previous revision @@ -22,8 +25,13 @@ async function submitRate( try { return await client.$transaction(async (tx) => { - const { rateID, rateRevisionID, submittedByUserID, submitReason } = - args + const { + rateID, + rateRevisionID, + submittedByUserID, + submitReason, + formData, + } = args // this is a hack that should not outlive protobuf. Protobufs only have // rate revision IDs in them, so we allow submitting by rate revisionID from our submitHPP resolver @@ -77,6 +85,21 @@ async function submitRate( return new Error(message) } + // update the rate with form data changes except for link/unlinking contracts. + if (formData) { + const updatedDraftRate = await updateDraftRate(tx, { + rateID: currentRev.rateID, + formData, + contractIDs: relatedContractRevs.map((cr) => cr.contractID), + }) + + if (updatedDraftRate instanceof Error) { + return updatedDraftRate + } + } + + // update rate with submit info, remove connected between rateRevision and contract, and making entries + // for rate and contract revisions on the RateRevisionsOnContractRevisionsTable. const updated = await tx.rateRevisionTable.update({ where: { id: currentRev.id, diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftRate.ts b/services/app-api/src/postgres/contractAndRates/updateDraftRate.ts index dfa72acceb..891fcfbded 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftRate.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftRate.ts @@ -4,7 +4,8 @@ import type { RateFormDataType, RateType, } from '../../domain-models/contractAndRates' -import type { PrismaClient } from '@prisma/client' +import type { PrismaTransactionType } from '../prismaTypes' +import { emptify, nullify } from '../prismaDomainAdaptors' type RateFormEditable = Partial @@ -26,7 +27,7 @@ type UpdateRateArgsType = { - No need for version history, preserving dates on related resources in draft form */ async function updateDraftRate( - client: PrismaClient, + client: PrismaTransactionType, args: UpdateRateArgsType ): Promise { const { rateID, formData, contractIDs } = args @@ -48,61 +49,83 @@ async function updateDraftRate( } = formData try { - return await client.$transaction(async (tx) => { - // Given all the Rates associated with this draft, find the most recent submitted to update. - const currentRev = await tx.rateRevisionTable.findFirst({ - where: { - rateID: rateID, - submitInfoID: null, + // Given all the Rates associated with this draft, find the most recent submitted to update. + const currentRev = await client.rateRevisionTable.findFirst({ + where: { + rateID: rateID, + submitInfoID: null, + }, + }) + if (!currentRev) { + console.error('No Draft Rev!') + return new Error('cant find a draft rev to submit') + } + // Clear all related resources on the revision + // Then update resource, adjusting all simple fields and creating new linked resources for fields holding relationships to other day + await client.rateRevisionTable.update({ + where: { + id: currentRev.id, + }, + data: { + rateType: nullify(rateType), + rateCapitationType: nullify(rateCapitationType), + + rateDocuments: { + deleteMany: {}, + create: + rateDocuments && + rateDocuments.map((d, idx) => ({ + position: idx, + ...d, + })), + }, + supportingDocuments: { + deleteMany: {}, + create: + supportingDocuments && + supportingDocuments.map((d, idx) => ({ + position: idx, + ...d, + })), }, - }) - if (!currentRev) { - console.error('No Draft Rev!') - return new Error('cant find a draft rev to submit') - } - // Clear all related resources on the revision - // Then update resource, adjusting all simple fields and creating new linked resources for fields holding relationships to other day - await tx.rateRevisionTable.update({ - where: { - id: currentRev.id, + certifyingActuaryContacts: { + deleteMany: {}, + create: + certifyingActuaryContacts && + certifyingActuaryContacts.map((c, idx) => ({ + position: idx, + ...c, + })), }, - data: { - rateType, - rateCapitationType, - - rateDocuments: { - deleteMany: {}, - create: rateDocuments, - }, - supportingDocuments: { - deleteMany: {}, - create: supportingDocuments, - }, - certifyingActuaryContacts: { - deleteMany: {}, - create: certifyingActuaryContacts, - }, - addtlActuaryContacts: { - deleteMany: {}, - create: addtlActuaryContacts, - }, - rateDateStart, - rateDateEnd, - rateDateCertified, - amendmentEffectiveDateStart, - amendmentEffectiveDateEnd, - rateProgramIDs, - rateCertificationName, - actuaryCommunicationPreference, - draftContracts: { - set: contractIDs.map((rID) => ({ - id: rID, + addtlActuaryContacts: { + deleteMany: {}, + create: + addtlActuaryContacts && + addtlActuaryContacts.map((c, idx) => ({ + position: idx, + ...c, })), - }, }, - }) - return findRateWithHistory(tx, rateID) + rateDateStart: nullify(rateDateStart), + rateDateEnd: nullify(rateDateEnd), + rateDateCertified: nullify(rateDateCertified), + amendmentEffectiveDateStart: nullify( + amendmentEffectiveDateStart + ), + amendmentEffectiveDateEnd: nullify(amendmentEffectiveDateEnd), + rateProgramIDs: emptify(rateProgramIDs), + rateCertificationName: nullify(rateCertificationName), + actuaryCommunicationPreference: nullify( + actuaryCommunicationPreference + ), + draftContracts: { + set: contractIDs.map((rID) => ({ + id: rID, + })), + }, + }, }) + return findRateWithHistory(client, rateID) } catch (err) { console.error('Prisma error updating rate', err) return err diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 24803f6cc9..0b0882dab7 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -33,6 +33,7 @@ import { fetchRateResolver } from './rate/fetchRate' import { updateContract } from './contract/updateContract' import { createAPIKeyResolver } from './APIKey' import { unlockRate } from './rate/unlockRate' +import { submitRate } from './rate/submitRate' export function configureResolvers( store: Store, @@ -90,6 +91,7 @@ export function configureResolvers( ), createAPIKey: createAPIKeyResolver(jwt), unlockRate: unlockRate(store), + submitRate: submitRate(store, launchDarkly), }, User: { // resolveType is required to differentiate Unions diff --git a/services/app-api/src/resolvers/rate/index.ts b/services/app-api/src/resolvers/rate/index.ts index f60abf548c..1ebd8d569f 100644 --- a/services/app-api/src/resolvers/rate/index.ts +++ b/services/app-api/src/resolvers/rate/index.ts @@ -1,2 +1,3 @@ export { rateResolver } from './rateResolver' export { indexRatesResolver } from './indexRates' +export { submitRate } from './submitRate' diff --git a/services/app-api/src/resolvers/rate/submitRate.test.ts b/services/app-api/src/resolvers/rate/submitRate.test.ts new file mode 100644 index 0000000000..5256f29438 --- /dev/null +++ b/services/app-api/src/resolvers/rate/submitRate.test.ts @@ -0,0 +1,256 @@ +import { testLDService } from '../../testHelpers/launchDarklyHelpers' +import { + constructTestPostgresServer, + createAndUpdateTestHealthPlanPackage, +} from '../../testHelpers/gqlHelpers' +import { testCMSUser, testStateUser } from '../../testHelpers/userHelpers' +import { latestFormData } from '../../testHelpers/healthPlanPackageHelpers' +import SUBMIT_RATE from '../../../../app-graphql/src/mutations/submitRate.graphql' +import FETCH_RATE from 'app-graphql/src/queries/fetchRate.graphql' +import SUBMIT_HEALTH_PLAN_PACKAGE from '../../../../app-graphql/src/mutations/submitHealthPlanPackage.graphql' +import UNLOCK_RATE from '../../../../app-graphql/src/mutations/unlockRate.graphql' + +describe('submitRate', () => { + const ldService = testLDService({ + 'rate-edit-unlock': true, + }) + + it('can submit rate without updates', async () => { + const stateUser = testStateUser() + + const stateServer = await constructTestPostgresServer({ + context: { + user: stateUser, + }, + ldService, + }) + + const draftContractWithRate = + await createAndUpdateTestHealthPlanPackage(stateServer) + + const rateID = latestFormData(draftContractWithRate).rateInfos[0].id + + const fetchDraftRate = await stateServer.executeOperation({ + query: FETCH_RATE, + variables: { + input: { rateID }, + }, + }) + + const draftFormData = + fetchDraftRate.data?.fetchRate.rate.draftRevision.formData + + // expect draft rate created in contract to exist + expect(fetchDraftRate.errors).toBeUndefined() + expect(draftFormData).toBeDefined() + + const result = await stateServer.executeOperation({ + query: SUBMIT_RATE, + variables: { + input: { + rateID: rateID, + submitReason: 'submit rate', + formData: draftFormData, + }, + }, + }) + + const submittedRate = result.data?.submitRate.rate + const submittedRateFormData = submittedRate.revisions[0].formData + + // expect no errors from submit rate + expect(result.errors).toBeUndefined() + // expect rate data to be returned + expect(submittedRate).toBeDefined() + // expect status to be submitted. + expect(submittedRate.status).toBe('SUBMITTED') + // expect formData to be the same + expect(submittedRateFormData).toEqual(draftFormData) + }) + it('can submit rate with formData updates', async () => { + const stateUser = testStateUser() + + const stateServer = await constructTestPostgresServer({ + context: { + user: stateUser, + }, + ldService, + }) + + const draftContractWithRate = + await createAndUpdateTestHealthPlanPackage(stateServer) + + const rateID = latestFormData(draftContractWithRate).rateInfos[0].id + + const fetchDraftRate = await stateServer.executeOperation({ + query: FETCH_RATE, + variables: { + input: { rateID }, + }, + }) + + const draftFormData = + fetchDraftRate.data?.fetchRate.rate.draftRevision.formData + + // expect draft rate created in contract to exist + expect(fetchDraftRate.errors).toBeUndefined() + expect(draftFormData).toBeDefined() + + // make update to formData in submit + const result = await stateServer.executeOperation({ + query: SUBMIT_RATE, + variables: { + input: { + rateID: rateID, + submitReason: 'submit rate', + formData: { + ...draftFormData, + rateType: 'AMENDMENT', + }, + }, + }, + }) + + const submittedRate = result.data?.submitRate.rate + const submittedRateFormData = submittedRate.revisions[0].formData + + // expect no errors from submit rate + expect(result.errors).toBeUndefined() + // expect rate data to be returned + expect(submittedRate).toBeDefined() + // expect status to be submitted. + expect(submittedRate.status).toBe('SUBMITTED') + // expect formData to NOT be the same + expect(submittedRateFormData).not.toEqual(draftFormData) + }) + it('can unlock and submit rate independent of contract status', async () => { + const stateUser = testStateUser() + const cmsUser = testCMSUser() + + const stateServer = await constructTestPostgresServer({ + context: { + user: stateUser, + }, + ldService, + }) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: cmsUser, + }, + ldService, + }) + + const draftContractWithRate = + await createAndUpdateTestHealthPlanPackage(stateServer) + const rateID = latestFormData(draftContractWithRate).rateInfos[0].id + + // submit contract and rate + await stateServer.executeOperation({ + query: SUBMIT_HEALTH_PLAN_PACKAGE, + variables: { + input: { + pkgID: draftContractWithRate.id, + }, + }, + }) + + // fetch newly created rate + const fetchDraftRate = await stateServer.executeOperation({ + query: FETCH_RATE, + variables: { + input: { rateID }, + }, + }) + const draftRate = fetchDraftRate.data?.fetchRate.rate + + // expect rate to have been submitted with contract + expect(draftRate.status).toBe('SUBMITTED') + + // unlocked the rate + const unlockedRateResult = await cmsServer.executeOperation({ + query: UNLOCK_RATE, + variables: { + input: { + rateID, + unlockedReason: 'unlock rate', + }, + }, + }) + const unlockedRate = unlockedRateResult.data?.unlockRate.rate + + // expect no errors from unlocking + expect(unlockedRateResult.errors).toBeUndefined() + // expect rate to be unlocked + expect(unlockedRate.status).toBe('UNLOCKED') + + // resubmit rate + const result = await stateServer.executeOperation({ + query: SUBMIT_RATE, + variables: { + input: { + rateID: rateID, + submitReason: 'submit rate', + formData: unlockedRate.draftRevision.formData, + }, + }, + }) + const submittedRate = result.data?.submitRate.rate + + // expect no errors from submit rate + expect(result.errors).toBeUndefined() + // expect rate to be resubmitted + expect(submittedRate.status).toBe('RESUBMITTED') + }) + it('errors when feature flag is off', async () => { + const stateUser = testStateUser() + + const stateServer = await constructTestPostgresServer({ + context: { + user: stateUser, + }, + ldService: testLDService({ + 'rate-edit-unlock': false, + }), + }) + + const draftContractWithRate = + await createAndUpdateTestHealthPlanPackage(stateServer) + + const rateID = latestFormData(draftContractWithRate).rateInfos[0].id + + const fetchDraftRate = await stateServer.executeOperation({ + query: FETCH_RATE, + variables: { + input: { rateID }, + }, + }) + + const draftFormData = + fetchDraftRate.data?.fetchRate.rate.draftRevision.formData + + // expect draft rate created in contract to exist + expect(fetchDraftRate.errors).toBeUndefined() + expect(draftFormData).toBeDefined() + + // make update to formData in submit + const result = await stateServer.executeOperation({ + query: SUBMIT_RATE, + variables: { + input: { + rateID: rateID, + submitReason: 'submit rate', + formData: { + ...draftFormData, + rateType: 'AMENDMENT', + }, + }, + }, + }) + + expect(result.errors).toBeDefined() + expect(result.errors?.[0].extensions?.message).toBe( + `Not authorized to submit rate, the feature is disabled` + ) + }) +}) diff --git a/services/app-api/src/resolvers/rate/submitRate.ts b/services/app-api/src/resolvers/rate/submitRate.ts new file mode 100644 index 0000000000..a14eb1605c --- /dev/null +++ b/services/app-api/src/resolvers/rate/submitRate.ts @@ -0,0 +1,153 @@ +import type { Store } from '../../postgres' +import type { MutationResolvers } from '../../gen/gqlServer' +import { + setErrorAttributesOnActiveSpan, + setResolverDetailsOnActiveSpan, +} from '../attributeHelper' +import type { RateFormDataType } from '../../domain-models' +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' + +export function submitRate( + store: Store, + launchDarkly: LDService +): MutationResolvers['submitRate'] { + return async (_parent, { input }, context) => { + const { user, span } = context + const { rateID, submitReason, formData } = input + const featureFlags = await launchDarkly.allFlags(context) + + setResolverDetailsOnActiveSpan('submitRate', user, span) + span?.setAttribute('mcreview.rate_id', rateID) + + // throw error if the feature flag is off + if (!featureFlags?.['rate-edit-unlock']) { + const errMessage = `Not authorized to submit rate, the feature is disabled` + logError('submitRate', 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('submitRate', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new ForbiddenError(errMessage) + } + + // find the rate to submit + const unsubmittedRate = await store.findRateWithHistory(rateID) + + if (unsubmittedRate instanceof Error) { + if (unsubmittedRate instanceof NotFoundError) { + const errMessage = `A rate must exist to be submitted: ${rateID}` + logError('submitRate', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage, { + argumentName: 'rateID', + }) + } + + logError('submitRate', unsubmittedRate.message) + setErrorAttributesOnActiveSpan(unsubmittedRate.message, span) + throw new GraphQLError(unsubmittedRate.message, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + const draftRateRevision = unsubmittedRate.draftRevision + + if (!draftRateRevision) { + throw new Error( + 'PROGRAMMING ERROR: Status should not be submittable without a draft rate revision' + ) + } + + // make sure it is draft or unlocked + if ( + unsubmittedRate.status === 'SUBMITTED' || + unsubmittedRate.status === 'RESUBMITTED' + ) { + const errMessage = `Attempted to submit a rate that is already submitted` + logError('submitRate', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new UserInputError(errMessage, { + argumentName: 'rateID', + cause: 'INVALID_PACKAGE_STATUS', + }) + } + + // call submit rate handler + const submittedRate = await store.submitRate({ + rateID, + submittedByUserID: user.id, + submitReason, + formData: { + rateType: (formData?.rateType ?? + undefined) as RateFormDataType['rateType'], + rateCapitationType: formData?.rateCapitationType ?? undefined, + rateDocuments: formData?.rateDocuments ?? [], + supportingDocuments: formData?.supportingDocuments ?? [], + rateDateStart: formData?.rateDateStart ?? undefined, + rateDateEnd: formData?.rateDateEnd ?? undefined, + rateDateCertified: formData?.rateDateCertified ?? undefined, + amendmentEffectiveDateStart: + formData?.amendmentEffectiveDateStart ?? undefined, + amendmentEffectiveDateEnd: + formData?.amendmentEffectiveDateEnd ?? undefined, + rateProgramIDs: formData?.rateProgramIDs ?? [], + rateCertificationName: + formData?.rateCertificationName ?? undefined, + certifyingActuaryContacts: formData?.certifyingActuaryContacts + ? formData?.certifyingActuaryContacts.map((contact) => ({ + name: contact.name ?? undefined, + titleRole: contact.titleRole ?? undefined, + email: contact.email ?? undefined, + actuarialFirm: contact.actuarialFirm ?? undefined, + actuarialFirmOther: + contact.actuarialFirmOther ?? undefined, + })) + : [], + addtlActuaryContacts: formData?.addtlActuaryContacts + ? formData?.addtlActuaryContacts.map((contact) => ({ + name: contact.name ?? undefined, + titleRole: contact.titleRole ?? undefined, + email: contact.email ?? undefined, + actuarialFirm: contact.actuarialFirm ?? undefined, + actuarialFirmOther: + contact.actuarialFirmOther ?? undefined, + })) + : [], + actuaryCommunicationPreference: + formData?.actuaryCommunicationPreference ?? undefined, + packagesWithSharedRateCerts: + formData?.packagesWithSharedRateCerts ?? [], + }, + }) + + if (submittedRate instanceof Error) { + const errMessage = `Failed to submit rate with ID: ${rateID}; ${submittedRate.message}` + logError('submitRate', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + return { + rate: submittedRate, + } + } +} diff --git a/services/app-graphql/src/mutations/submitRate.graphql b/services/app-graphql/src/mutations/submitRate.graphql new file mode 100644 index 0000000000..87a1be9562 --- /dev/null +++ b/services/app-graphql/src/mutations/submitRate.graphql @@ -0,0 +1,143 @@ +mutation submitRate($input: SubmitRateInput!) { + submitRate(input: $input) { + rate { + id + createdAt + updatedAt + stateCode + stateNumber + state { + code + name + programs { + id + name + fullName + } + } + status + initiallySubmittedAt + revisions { + 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 { + name + titleRole + email + actuarialFirm + actuarialFirmOther + }, + addtlActuaryContacts { + 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, + title + 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 + } + } + } + } + } +} diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index 035e443609..b6e1b77035 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -302,6 +302,30 @@ type Mutation { input: UnlockRateInput! ): UnlockRatePayload! + """ + submitRate will submit an unlocked rate and return the submitted rate data. + + This can only be called by a StateUser. + The rate must be in the DRAFT or UNLOCKED state to be submitted. + + Errors: + - ForbiddenError: + - A non State user called this + - UserInputError + - A rate cannot be found with the given `rateID` + - INTERNAL_SERVER_ERROR + - DB_ERROR + - Postgres returns error when attempting to finding rate + - Postgres returns error when attempting to update rate + - Postgres returns error when attempting to submit rate + - Postgres returns error when both rateID or rateRevisionID are blank. + - Postgres returns error when current rate revision to submit cannot be found + - INVALID_PACKAGE_STATUS + - Attempted to unlock a rate in the DRAFT or UNLOCKED state + """ + submitRate( + input: SubmitRateInput! + ): SubmitRatePayload! } input CreateHealthPlanPackageInput { @@ -778,6 +802,20 @@ type GenericDocument { sha256: String! } +""" +GenericDocumentInput + +This document input should be used (or extended) everywhere we pass documents through GraphQL regardless of domain +""" +input GenericDocumentInput { + "The user created name of the document" + name: String! + "The S3 URL of the document, generated on the FE currently in the FileUpload component" + s3URL: String! + "The sha256 is a unique string representing the file, generated on the FE currently in the FileUpload component" + sha256: String! +} + "The large overarching population of people that the program covers." enum PopulationCovered { MEDICAID @@ -974,7 +1012,7 @@ enum ActuarialFirm { } "Contact information for the certifying or additional state actuary" -type ActuaryContact { +type ActuaryContact { name: String titleRole: String email: String @@ -982,6 +1020,15 @@ type ActuaryContact { actuarialFirmOther: String } +"Contact information input for the certifying or additional state actuary" +input ActuaryContactInput { + name: String + titleRole: String + email: String + actuarialFirm: ActuarialFirm + actuarialFirmOther: String +} + """ A package in the system that shares a rate with another package. @@ -993,6 +1040,12 @@ type PackageWithSameRate { packageStatus: String } +input PackageWithSameRateInput { + packageName: String! + packageId: String! + packageStatus: HealthPlanPackageStatus! +} + """ RateFormData represents the form data that was inputted by the state This type is used for the form data field found on a rate revision @@ -1194,8 +1247,106 @@ type UnlockRatePayload { rate: Rate! } -input UnlockRateInput{ +type SubmitRatePayload { + rate: Rate! +} + +input UnlockRateInput { rateID: ID! "User given reason this rate was unlocked" unlockedReason: String! } + +input RateFormDataInput { + """ + Can be 'NEW' or 'AMENDMENT' + Refers to whether the state is submitting a brand new rate certification + or an amendment to an existing rate certification + """ + rateType: RateType + """ + Can be 'RATE_CELL' or 'RATE_RANGE' + These values represent on what basis the capitation rate is actuarially sound + """ + rateCapitationType: RateCapitationType + """ + Signed certification documents the state uploads + Files can be PDF, DOC, or DOCX format + """ + rateDocuments: [GenericDocumentInput!]! + """ + Additional documents the state uploads to support a rate cert + Files can be PDF, DOC, DOCX, XLSX, CSV format + """ + supportingDocuments: [GenericDocumentInput!]! + """ + If the rateType is NEW this is the start date of the + rating period for a new certification. + If the rateType is AMENDMENT this is the start date of the + rating period for the original rate certification + """ + rateDateStart: Date + """ + If the rateType is NEW this is the end date of the + rating period for a new certification. + If the rateType is AMENDMENT this is the end date of the + rating period for the original rate certification + """ + rateDateEnd: Date + """ + The date the rate certification was + certified/signed by the state's actuary + """ + rateDateCertified: Date + """ + The start date of the rate amendment + Only relevant if rate type is AMENDMENT + """ + amendmentEffectiveDateStart: Date + """ + The end date of the rate amendment + Only relevant if rate type is AMENDMENT + """ + amendmentEffectiveDateEnd: Date + """ + An array of IDs representing state programs that the rate covers + """ + rateProgramIDs: [String!]! + """ + Represents the name of the rate. + This value is auto generated based on rate, package and state program details + """ + rateCertificationName: String + """ + An array of ActuaryContacts + Each element includes the the name, title/role and email + of the actuaries who certified the rate + """ + certifyingActuaryContacts: [ActuaryContactInput!]! + """ + An array of additional ActuaryContacts + Each element includes the the name, title/role and email + """ + addtlActuaryContacts: [ActuaryContactInput!]! + """ + Is either OACT_TO_ACTUARY or OACT_TO_STATE + It specifies whether the state wants CMS to reach out to their actuaries + directly or go through them + """ + actuaryCommunicationPreference: ActuaryCommunication + """ + An array of PackageWithSameRate elements + which contain the packageName, packageId, and packageStatus + These elements represent other packages in the system + that are using this rate + """ + packagesWithSharedRateCerts: [PackageWithSameRateInput!]! +} + +input SubmitRateInput { + rateID: ID! + "User given submission description" + submitReason: String! + "Rate related form data to be updated with submission" + formData: RateFormDataInput! +} From 9ab532bb04abf9db05dd8b8e23d3734de7e73980 Mon Sep 17 00:00:00 2001 From: Jason Lin Date: Tue, 23 Jan 2024 15:21:07 -0500 Subject: [PATCH 2/8] Cleanup and add more info to error messages. --- services/app-api/src/domain-models/healthPlanPackage.ts | 4 +++- .../postgres/contractAndRates/prismaDraftRatesHelpers.ts | 7 +------ services/app-api/src/resolvers/rate/unlockRate.test.ts | 1 - .../proto/healthPlanFormDataProto/zodSchemas.ts | 5 ++++- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/services/app-api/src/domain-models/healthPlanPackage.ts b/services/app-api/src/domain-models/healthPlanPackage.ts index c0a1086865..9bbee57383 100644 --- a/services/app-api/src/domain-models/healthPlanPackage.ts +++ b/services/app-api/src/domain-models/healthPlanPackage.ts @@ -30,7 +30,9 @@ function packageStatus( return 'UNLOCKED' } - return new Error('No revisions on this submission') + return new Error( + `No revisions on this submission with contractID: ${pkg.id}` + ) } // submissionSubmittedAt returns the INITIAL submission date. Even if the diff --git a/services/app-api/src/postgres/contractAndRates/prismaDraftRatesHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaDraftRatesHelpers.ts index 9b844154c7..71ec87ddb9 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaDraftRatesHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaDraftRatesHelpers.ts @@ -8,17 +8,12 @@ import { contractRevisionToDomainModel } from './parseContractWithHistory' import { convertUpdateInfoToDomainModel, includeContractFormData, - includeUpdateInfo, rateFormDataToDomainModel, } from './prismaSharedContractRateHelpers' const includeDraftContracts = { revisions: { - include: { - ...includeContractFormData, - submitInfo: includeUpdateInfo, - unlockInfo: includeUpdateInfo, - }, + include: includeContractFormData, take: 1, orderBy: { createdAt: 'desc', diff --git a/services/app-api/src/resolvers/rate/unlockRate.test.ts b/services/app-api/src/resolvers/rate/unlockRate.test.ts index 03a65f4135..6612ad4770 100644 --- a/services/app-api/src/resolvers/rate/unlockRate.test.ts +++ b/services/app-api/src/resolvers/rate/unlockRate.test.ts @@ -41,7 +41,6 @@ describe(`unlockRate`, () => { const updatedRate = unlockResult.data?.unlockRate.rate - console.info(updatedRate) expect(updatedRate.status).toBe('UNLOCKED') expect(updatedRate.draftRevision).toBeDefined() expect(updatedRate.draftRevision.unlockInfo.updatedReason).toEqual( diff --git a/services/app-web/src/common-code/proto/healthPlanFormDataProto/zodSchemas.ts b/services/app-web/src/common-code/proto/healthPlanFormDataProto/zodSchemas.ts index 1540fec261..537498e3cb 100644 --- a/services/app-web/src/common-code/proto/healthPlanFormDataProto/zodSchemas.ts +++ b/services/app-web/src/common-code/proto/healthPlanFormDataProto/zodSchemas.ts @@ -108,7 +108,10 @@ const sharedRateCertDisplay = z.object({ packageId: z.string(), }) -const rateTypeSchema = z.union([z.literal('NEW'), z.literal('AMENDMENT')]) +const rateTypeSchema = z.union([ + z.literal('NEW'), + z.literal('AMENDMENT') +]) const rateCapitationTypeSchema = z.union([ z.literal('RATE_CELL'), From 991f979535c616f0b8e6d93bd63b9fabdf3c0471 Mon Sep 17 00:00:00 2001 From: Jason Lin Date: Tue, 23 Jan 2024 15:21:19 -0500 Subject: [PATCH 3/8] SubmitRate tests --- .../contractAndRates/submitRate.test.ts | 253 +++++++++++++++++- 1 file changed, 251 insertions(+), 2 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/submitRate.test.ts b/services/app-api/src/postgres/contractAndRates/submitRate.test.ts index bb8290ebad..e2cd1c236f 100644 --- a/services/app-api/src/postgres/contractAndRates/submitRate.test.ts +++ b/services/app-api/src/postgres/contractAndRates/submitRate.test.ts @@ -3,11 +3,20 @@ import { v4 as uuidv4 } from 'uuid' import { submitRate } from './submitRate' import { NotFoundError } from '../postgresErrors' import { createInsertRateData } from '../../testHelpers/contractAndRates/rateHelpers' -import { createInsertContractData, must } from '../../testHelpers' +import { + consoleLogFullData, + createInsertContractData, + must, +} from '../../testHelpers' import { insertDraftRate } from './insertRate' import { submitContract } from './submitContract' import { insertDraftContract } from './insertContract' import { updateDraftContractWithRates } from './updateDraftContractWithRates' +import type { RateFormEditable } from './updateDraftRate' +import { unlockRate } from './unlockRate' +import { findContractWithHistory } from './findContractWithHistory' +import { findStatePrograms } from '../state' +import { unlockContract } from './unlockContract' describe('submitRate', () => { it('creates a standalone rate submission from a draft', async () => { @@ -43,17 +52,24 @@ describe('submitRate', () => { rateID: rateA.id, submittedByUserID: stateUser.id, submitReason: 'initial submit', + formData: { + ...draftRateData, + rateType: 'AMENDMENT', + }, }) ) expect(result.revisions[0].submitInfo?.updatedReason).toBe( 'initial submit' ) + consoleLogFullData(result.revisions[0]) + //Expect rate form data to be what was inserted expect(result.revisions[0]).toEqual( expect.objectContaining({ formData: expect.objectContaining({ rateCertificationName: 'rate-cert-name', + rateType: 'AMENDMENT', }), }) ) @@ -187,6 +203,12 @@ describe('submitRate', () => { }) const rateA = must(await insertDraftRate(client, draftRateData)) + if (!rateA.draftRevision) { + throw new Error( + 'Unexpected error: No draft rate revision found in draft rate' + ) + } + // Attempt to submit a contract related to this draft rate const contract1 = must( await insertDraftContract(client, { @@ -201,7 +223,7 @@ describe('submitRate', () => { await updateDraftContractWithRates(client, { contractID: contract1.id, formData: { submissionDescription: 'onepoint0' }, - rateFormDatas: [rateA], + rateFormDatas: [rateA.draftRevision?.formData], }) ) @@ -214,8 +236,235 @@ describe('submitRate', () => { if (!(result instanceof Error)) { throw new Error('must be an error') } + expect(result.message).toBe( 'Attempted to submit a contract related to a rate that has not been submitted.' ) }) + it('submits rate with updates', async () => { + const client = await sharedTestPrismaClient() + + const stateUser = must( + await client.user.create({ + data: { + id: uuidv4(), + givenName: 'Aang', + familyName: 'Avatar', + email: 'aang@example.com', + role: 'STATE_USER', + stateCode: 'NM', + }, + }) + ) + + // create a draft rate + const draftRateData = createInsertRateData({ + rateCertificationName: 'first rate ', + }) + const draftRate = must(await insertDraftRate(client, draftRateData)) + + if (!draftRate.draftRevision) { + throw new Error( + 'Unexpected error: No draft rate revision in draft rate' + ) + } + + const rateID = draftRate.draftRevision.rate.id + + const statePrograms = must(findStatePrograms(draftRate.stateCode)) + + const updateRateData: RateFormEditable = { + ...draftRate.draftRevision.formData, + rateType: 'NEW', + rateID, + rateCertificationName: 'testState-123', + rateProgramIDs: [statePrograms[0].id], + rateCapitationType: 'RATE_CELL', + rateDateStart: new Date('2024-01-01'), + rateDateEnd: new Date('2025-01-01'), + rateDateCertified: new Date('2024-01-01'), + amendmentEffectiveDateEnd: new Date('2024-02-01'), + amendmentEffectiveDateStart: new Date('2025-02-01'), + actuaryCommunicationPreference: 'OACT_TO_ACTUARY', + certifyingActuaryContacts: [], + addtlActuaryContacts: [], + supportingDocuments: [ + { + name: 'rate supporting doc', + s3URL: 'fakeS3URL', + sha256: '2342fwlkdmwvw', + }, + { + name: 'rate supporting doc 2', + s3URL: 'fakeS3URL', + sha256: '45662342fwlkdmwvw', + }, + ], + rateDocuments: [ + { + name: 'contract doc', + s3URL: 'fakeS3URL', + sha256: '8984234fwlkdmwvw', + }, + ], + } + + const submittedRate = must( + await submitRate(client, { + rateID, + submittedByUserID: stateUser.id, + submitReason: 'submit and update rate', + formData: updateRateData, + }) + ) + + expect(submittedRate.revisions[0].formData).toEqual( + expect.objectContaining(updateRateData) + ) + }) + it('submits rate independent of contract status', async () => { + const client = await sharedTestPrismaClient() + + const stateUser = must( + await client.user.create({ + data: { + id: uuidv4(), + givenName: 'Aang', + familyName: 'Avatar', + email: 'aang@example.com', + role: 'STATE_USER', + stateCode: 'NM', + }, + }) + ) + + const cmsUser = await client.user.create({ + data: { + id: uuidv4(), + givenName: 'Zuko', + familyName: 'Hotman', + email: 'zuko@example.com', + role: 'CMS_USER', + }, + }) + + // create a draft contract + const draftContract = must( + await insertDraftContract( + client, + createInsertContractData({ + submissionDescription: 'first contract', + }) + ) + ) + + const contractID = draftContract.id + + // add new rate to contract + const updatedDraftContract = must( + await updateDraftContractWithRates(client, { + contractID, + formData: {}, + rateFormDatas: [ + createInsertRateData({ + rateCertificationName: 'rate revision 1.0', + rateType: 'NEW', + }), + ], + }) + ) + + if (!updatedDraftContract.draftRevision) { + throw new Error( + 'Unexpected error: draft revision not found in draft contract' + ) + } + + const rateID = + updatedDraftContract.draftRevision.rateRevisions[0].rate.id + + // submit rate + const submittedRate = must( + await submitRate(client, { + rateID, + submittedByUserID: stateUser.id, + submitReason: 'submit and update rate', + formData: { + rateCertificationName: 'rate revision 1.1', + rateType: 'AMENDMENT', + }, + }) + ) + + // expect submitted rate not to have error. + expect(submittedRate).not.toBeInstanceOf(Error) + + const fetchedDraftContract = must( + await findContractWithHistory(client, contractID) + ) + + if (!fetchedDraftContract.draftRevision) { + throw new Error( + 'Unexpected error: draft revision not found in draft contract' + ) + } + + // expect updated and submitted rate revision to be on draft contract revision + expect( + fetchedDraftContract.draftRevision.rateRevisions[0].formData + ).toEqual( + expect.objectContaining({ + rateCertificationName: 'rate revision 1.1', + rateType: 'AMENDMENT', + }) + ) + + const submittedContract = must( + await submitContract(client, { + contractID: draftContract.id, + submittedByUserID: stateUser.id, + submitReason: 'submit first contract', + }) + ) + + // expect updated and submitted rate revision to be on submitted contract revision + expect( + submittedContract.revisions[0].rateRevisions[0].formData + ).toEqual( + expect.objectContaining({ + rateCertificationName: 'rate revision 1.1', + rateType: 'AMENDMENT', + }) + ) + + // Rate should be able to unlock and resubmitted + must( + await unlockRate(client, { + rateID, + unlockedByUserID: cmsUser.id, + unlockReason: 'some reason', + }) + ) + + must( + await unlockContract(client, { + contractID: draftContract.id, + unlockReason: 'dosmsdfs', + unlockedByUserID: cmsUser.id, + }) + ) + + must( + await submitRate(client, { + rateID, + submittedByUserID: stateUser.id, + submitReason: 'submit and update rate', + formData: { + rateCertificationName: 'rate revision 1.2', + rateType: 'AMENDMENT', + rateCapitationType: 'RATE_CELL', + }, + }) + ) + }) }) From 2b1013abf2e97389c6efad28ef36a2ddcf70488e Mon Sep 17 00:00:00 2001 From: Jason Lin Date: Tue, 23 Jan 2024 15:21:51 -0500 Subject: [PATCH 4/8] Fix bug with trying to findFirst with undefined id. --- .../updateDraftContractWithRates.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts index 90d039657c..27e237d887 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts @@ -176,31 +176,32 @@ async function updateDraftContractWithRates( ratesFromDB.push(domainRateRevision) } + // Parsing rates from request for update or create const updateRates = rateFormDatas && sortRatesForUpdate(ratesFromDB, rateFormDatas) if (updateRates) { for (const rateFormData of updateRates.upsertRates) { - // Check if the rate exists - // - We don't know if the rate revision exists in the DB we just know it's not connected to the contract. - // - toProtoBuffer gives every rate revision a UUID if there isn't one, so we cannot rely on revision id. - // - We can use this revision id to check if a rate and revision exists. + // Current rate with the latest revision + let currentRate = undefined - // Find the rate of the revision with only one draft revision - const currentRate = await tx.rateTable.findFirst({ - where: { - id: rateFormData.id, - }, - include: { - // include the single most recent revision that is not submitted - revisions: { - where: { - submitInfoID: null, + // If no rate id is undefined we know this is a new rate that needs to be inserted into the DB. + if (rateFormData.rateID) { + currentRate = await tx.rateTable.findUnique({ + where: { + id: rateFormData.id, + }, + include: { + // include the single most recent revision that is not submitted + revisions: { + where: { + submitInfoID: null, + }, + take: 1, }, - take: 1, }, - }, - }) + }) + } const contractsWithSharedRates = rateFormData.packagesWithSharedRateCerts?.map( From f9d10e9c9c17f07776adac39b7ff82d2a297837b Mon Sep 17 00:00:00 2001 From: Jason Lin Date: Tue, 23 Jan 2024 16:07:12 -0500 Subject: [PATCH 5/8] Update error message in test. --- services/app-api/src/domain-models/healthPlanPackage.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/app-api/src/domain-models/healthPlanPackage.test.ts b/services/app-api/src/domain-models/healthPlanPackage.test.ts index 8b48fdef43..a1be290027 100644 --- a/services/app-api/src/domain-models/healthPlanPackage.test.ts +++ b/services/app-api/src/domain-models/healthPlanPackage.test.ts @@ -124,7 +124,9 @@ describe('HealthPlanPackage helpers', () => { stateCode: 'FL' as const, revisions: [], }, - new Error('No revisions on this submission'), + new Error( + 'No revisions on this submission with contractID: foo' + ), ], ] From f0e067191a7d32259b86f7ef1e3e92e4d9e77376 Mon Sep 17 00:00:00 2001 From: Jason Lin Date: Tue, 23 Jan 2024 16:11:19 -0500 Subject: [PATCH 6/8] Remove testing console logs. --- .../postgres/contractAndRates/findRateWithHistory.test.ts | 7 +------ .../src/postgres/contractAndRates/submitRate.test.ts | 8 +------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts index 3dc57d15b0..ab2c8c5d13 100644 --- a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts @@ -9,11 +9,7 @@ import { insertDraftRate } from './insertRate' import { updateDraftRate } from './updateDraftRate' import { unlockRate } from './unlockRate' import { findRateWithHistory } from './findRateWithHistory' -import { - must, - createInsertContractData, - consoleLogFullData, -} from '../../testHelpers' +import { must, createInsertContractData } from '../../testHelpers' import { createInsertRateData } from '../../testHelpers/contractAndRates/rateHelpers' import { findContractWithHistory } from './findContractWithHistory' import type { DraftContractType } from '../../domain-models/contractAndRates/contractTypes' @@ -819,7 +815,6 @@ describe('findRate', () => { ) let submittedRate = must(await findRateWithHistory(client, rateID)) - consoleLogFullData(submittedRate) // Expect rate revision 1.0 to have contract revision 1.0 expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( diff --git a/services/app-api/src/postgres/contractAndRates/submitRate.test.ts b/services/app-api/src/postgres/contractAndRates/submitRate.test.ts index e2cd1c236f..b3db78ee86 100644 --- a/services/app-api/src/postgres/contractAndRates/submitRate.test.ts +++ b/services/app-api/src/postgres/contractAndRates/submitRate.test.ts @@ -3,11 +3,7 @@ import { v4 as uuidv4 } from 'uuid' import { submitRate } from './submitRate' import { NotFoundError } from '../postgresErrors' import { createInsertRateData } from '../../testHelpers/contractAndRates/rateHelpers' -import { - consoleLogFullData, - createInsertContractData, - must, -} from '../../testHelpers' +import { createInsertContractData, must } from '../../testHelpers' import { insertDraftRate } from './insertRate' import { submitContract } from './submitContract' import { insertDraftContract } from './insertContract' @@ -62,8 +58,6 @@ describe('submitRate', () => { 'initial submit' ) - consoleLogFullData(result.revisions[0]) - //Expect rate form data to be what was inserted expect(result.revisions[0]).toEqual( expect.objectContaining({ From f339749106ab707a53d1e8f97e997bb4c7655a03 Mon Sep 17 00:00:00 2001 From: Jason Lin Date: Wed, 24 Jan 2024 15:57:59 -0500 Subject: [PATCH 7/8] Make requested PR changes. --- .../postgres/contractAndRates/updateDraftContractWithRates.ts | 3 +++ .../app-api/src/postgres/contractAndRates/updateDraftRate.ts | 3 +++ services/app-api/src/resolvers/rate/submitRate.test.ts | 2 +- services/app-api/src/resolvers/rate/submitRate.ts | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts index 27e237d887..91a01546a2 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts @@ -198,6 +198,9 @@ async function updateDraftContractWithRates( submitInfoID: null, }, take: 1, + orderBy: { + createdAt: 'desc', + }, }, }, }) diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftRate.ts b/services/app-api/src/postgres/contractAndRates/updateDraftRate.ts index 891fcfbded..c2278f46c3 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftRate.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftRate.ts @@ -55,6 +55,9 @@ async function updateDraftRate( rateID: rateID, submitInfoID: null, }, + orderBy: { + createdAt: 'desc', + }, }) if (!currentRev) { console.error('No Draft Rev!') diff --git a/services/app-api/src/resolvers/rate/submitRate.test.ts b/services/app-api/src/resolvers/rate/submitRate.test.ts index 5256f29438..0731f0e808 100644 --- a/services/app-api/src/resolvers/rate/submitRate.test.ts +++ b/services/app-api/src/resolvers/rate/submitRate.test.ts @@ -250,7 +250,7 @@ describe('submitRate', () => { expect(result.errors).toBeDefined() expect(result.errors?.[0].extensions?.message).toBe( - `Not authorized to submit rate, the feature is disabled` + `Not authorized to edit and submit a rate independently, the feature is disabled` ) }) }) diff --git a/services/app-api/src/resolvers/rate/submitRate.ts b/services/app-api/src/resolvers/rate/submitRate.ts index a14eb1605c..8b26125e58 100644 --- a/services/app-api/src/resolvers/rate/submitRate.ts +++ b/services/app-api/src/resolvers/rate/submitRate.ts @@ -26,7 +26,7 @@ export function submitRate( // throw error if the feature flag is off if (!featureFlags?.['rate-edit-unlock']) { - const errMessage = `Not authorized to submit rate, the feature is disabled` + const errMessage = `Not authorized to edit and submit a rate independently, the feature is disabled` logError('submitRate', errMessage) throw new ForbiddenError(errMessage, { message: errMessage, From dd6d03d45e9884bb300568b88bde115cbac9254b Mon Sep 17 00:00:00 2001 From: Jason Lin Date: Thu, 25 Jan 2024 10:35:38 -0500 Subject: [PATCH 8/8] Can submit rate without fromData changes. --- .../src/resolvers/rate/submitRate.test.ts | 52 +++++++++++ .../app-api/src/resolvers/rate/submitRate.ts | 93 +++++++++++-------- services/app-graphql/src/schema.graphql | 2 +- 3 files changed, 105 insertions(+), 42 deletions(-) diff --git a/services/app-api/src/resolvers/rate/submitRate.test.ts b/services/app-api/src/resolvers/rate/submitRate.test.ts index 0731f0e808..06f05c7063 100644 --- a/services/app-api/src/resolvers/rate/submitRate.test.ts +++ b/services/app-api/src/resolvers/rate/submitRate.test.ts @@ -123,6 +123,58 @@ describe('submitRate', () => { // expect formData to NOT be the same expect(submittedRateFormData).not.toEqual(draftFormData) }) + it('can submit rate without formData updates', async () => { + const stateUser = testStateUser() + + const stateServer = await constructTestPostgresServer({ + context: { + user: stateUser, + }, + ldService, + }) + + const draftContractWithRate = + await createAndUpdateTestHealthPlanPackage(stateServer) + + const rateID = latestFormData(draftContractWithRate).rateInfos[0].id + + const fetchDraftRate = await stateServer.executeOperation({ + query: FETCH_RATE, + variables: { + input: { rateID }, + }, + }) + + const draftFormData = + fetchDraftRate.data?.fetchRate.rate.draftRevision.formData + + // expect draft rate created in contract to exist + expect(fetchDraftRate.errors).toBeUndefined() + expect(draftFormData).toBeDefined() + + // make update to formData in submit + const result = await stateServer.executeOperation({ + query: SUBMIT_RATE, + variables: { + input: { + rateID: rateID, + submitReason: 'submit rate', + }, + }, + }) + + const submittedRate = result.data?.submitRate.rate + const submittedRateFormData = submittedRate.revisions[0].formData + + // expect no errors from submit rate + expect(result.errors).toBeUndefined() + // expect rate data to be returned + expect(submittedRate).toBeDefined() + // expect status to be submitted. + expect(submittedRate.status).toBe('SUBMITTED') + // expect formData to be the same + expect(submittedRateFormData).toEqual(draftFormData) + }) it('can unlock and submit rate independent of contract status', async () => { const stateUser = testStateUser() const cmsUser = testCMSUser() diff --git a/services/app-api/src/resolvers/rate/submitRate.ts b/services/app-api/src/resolvers/rate/submitRate.ts index 8b26125e58..e677d26ec4 100644 --- a/services/app-api/src/resolvers/rate/submitRate.ts +++ b/services/app-api/src/resolvers/rate/submitRate.ts @@ -91,47 +91,58 @@ export function submitRate( rateID, submittedByUserID: user.id, submitReason, - formData: { - rateType: (formData?.rateType ?? - undefined) as RateFormDataType['rateType'], - rateCapitationType: formData?.rateCapitationType ?? undefined, - rateDocuments: formData?.rateDocuments ?? [], - supportingDocuments: formData?.supportingDocuments ?? [], - rateDateStart: formData?.rateDateStart ?? undefined, - rateDateEnd: formData?.rateDateEnd ?? undefined, - rateDateCertified: formData?.rateDateCertified ?? undefined, - amendmentEffectiveDateStart: - formData?.amendmentEffectiveDateStart ?? undefined, - amendmentEffectiveDateEnd: - formData?.amendmentEffectiveDateEnd ?? undefined, - rateProgramIDs: formData?.rateProgramIDs ?? [], - rateCertificationName: - formData?.rateCertificationName ?? undefined, - certifyingActuaryContacts: formData?.certifyingActuaryContacts - ? formData?.certifyingActuaryContacts.map((contact) => ({ - name: contact.name ?? undefined, - titleRole: contact.titleRole ?? undefined, - email: contact.email ?? undefined, - actuarialFirm: contact.actuarialFirm ?? undefined, - actuarialFirmOther: - contact.actuarialFirmOther ?? undefined, - })) - : [], - addtlActuaryContacts: formData?.addtlActuaryContacts - ? formData?.addtlActuaryContacts.map((contact) => ({ - name: contact.name ?? undefined, - titleRole: contact.titleRole ?? undefined, - email: contact.email ?? undefined, - actuarialFirm: contact.actuarialFirm ?? undefined, - actuarialFirmOther: - contact.actuarialFirmOther ?? undefined, - })) - : [], - actuaryCommunicationPreference: - formData?.actuaryCommunicationPreference ?? undefined, - packagesWithSharedRateCerts: - formData?.packagesWithSharedRateCerts ?? [], - }, + formData: formData + ? { + rateType: (formData.rateType ?? + undefined) as RateFormDataType['rateType'], + rateCapitationType: + formData.rateCapitationType ?? undefined, + rateDocuments: formData.rateDocuments ?? [], + supportingDocuments: formData.supportingDocuments ?? [], + rateDateStart: formData.rateDateStart ?? undefined, + rateDateEnd: formData.rateDateEnd ?? undefined, + rateDateCertified: + formData.rateDateCertified ?? undefined, + amendmentEffectiveDateStart: + formData.amendmentEffectiveDateStart ?? undefined, + amendmentEffectiveDateEnd: + formData.amendmentEffectiveDateEnd ?? undefined, + rateProgramIDs: formData.rateProgramIDs ?? [], + rateCertificationName: + formData.rateCertificationName ?? undefined, + certifyingActuaryContacts: + formData.certifyingActuaryContacts + ? formData.certifyingActuaryContacts.map( + (contact) => ({ + name: contact.name ?? undefined, + titleRole: + contact.titleRole ?? undefined, + email: contact.email ?? undefined, + actuarialFirm: + contact.actuarialFirm ?? undefined, + actuarialFirmOther: + contact.actuarialFirmOther ?? + undefined, + }) + ) + : [], + addtlActuaryContacts: formData.addtlActuaryContacts + ? formData.addtlActuaryContacts.map((contact) => ({ + name: contact.name ?? undefined, + titleRole: contact.titleRole ?? undefined, + email: contact.email ?? undefined, + actuarialFirm: + contact.actuarialFirm ?? undefined, + actuarialFirmOther: + contact.actuarialFirmOther ?? undefined, + })) + : [], + actuaryCommunicationPreference: + formData.actuaryCommunicationPreference ?? undefined, + packagesWithSharedRateCerts: + formData.packagesWithSharedRateCerts ?? [], + } + : undefined, }) if (submittedRate instanceof Error) { diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index b6e1b77035..deeee2f15a 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -1348,5 +1348,5 @@ input SubmitRateInput { "User given submission description" submitReason: String! "Rate related form data to be updated with submission" - formData: RateFormDataInput! + formData: RateFormDataInput }