diff --git a/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts b/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts index 5c2a9beee8..298029d4f4 100644 --- a/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts @@ -134,15 +134,17 @@ function contractWithHistoryToDomainModel( const draftPrismaRates = contractRev.draftRates - draftRates = draftPrismaRates.map((r) => ({ - id: r.id, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - status: getContractRateStatus(r.revisions), - stateCode: r.stateCode, - stateNumber: r.stateNumber, - revisions: [], - })) + 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 } diff --git a/services/app-api/src/postgres/contractAndRates/prismaDraftContractHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaDraftContractHelpers.ts index e32cba91ea..9a2e1256a6 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaDraftContractHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaDraftContractHelpers.ts @@ -14,7 +14,6 @@ import type { ContractRevisionTableWithRates } from './prismaSubmittedContractHe const includeDraftRates = { revisions: { include: includeRateFormData, - take: 1, orderBy: { createdAt: 'desc', }, diff --git a/services/app-api/src/resolvers/contract/updateDraftContractRates.test.ts b/services/app-api/src/resolvers/contract/updateDraftContractRates.test.ts index 7137c5eab2..398ac0f704 100644 --- a/services/app-api/src/resolvers/contract/updateDraftContractRates.test.ts +++ b/services/app-api/src/resolvers/contract/updateDraftContractRates.test.ts @@ -5,6 +5,7 @@ import { createAndSubmitTestHealthPlanPackage, createAndUpdateTestHealthPlanPackage, createTestHealthPlanPackage, + unlockTestHealthPlanPackage, } from '../../testHelpers/gqlHelpers' import { createTestDraftRateOnContract, @@ -401,7 +402,82 @@ describe('updateDraftContractRates', () => { expect(draftFormData.rateDocuments[0].name).toBe('updatedratedoc1.doc') }) - it('doesnt allow updating a non-linked rate', async () => { + it('doesnt allow updating a non-existent rate', async () => { + const stateServer = await constructTestPostgresServer() + + const draft = await createAndUpdateTestHealthPlanPackage(stateServer) + + const result = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: { + contractID: draft.id, + updatedRates: [ + { + type: 'UPDATE', + rateID: 'foo-bar', + 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: 'updatedratedoc1.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).toBeDefined() + expect(result.errors?.[0].extensions?.code).toBe('BAD_USER_INPUT') + expect(result.errors?.[0].message).toBe( + 'Attempted to update a rate not associated with this contract: foo-bar' + ) + }) + + it('doesnt allow updating an unassociated rate', async () => { const stateServer = await constructTestPostgresServer() const draft = await createAndUpdateTestHealthPlanPackage(stateServer) @@ -474,6 +550,9 @@ describe('updateDraftContractRates', () => { }) expect(result.errors).toBeDefined() expect(result.errors?.[0].extensions?.code).toBe('BAD_USER_INPUT') + expect(result.errors?.[0].message).toContain( + 'Attempted to update a rate not associated with this contract' + ) }) it('allows creating and updating a partial rate', async () => { @@ -576,6 +655,267 @@ describe('updateDraftContractRates', () => { expect(rateFormData.rateDocuments[0].name).toBe('rateDocument.pdf') }) + it('doesnt allow updating a linked rate', async () => { + const stateServer = await constructTestPostgresServer() + + const otherPackage = + await createAndSubmitTestHealthPlanPackage(stateServer) + + const otherFD = latestFormData(otherPackage) + const foreignRateID = otherFD.rateInfos[0].id + + const contractDraft = await createTestHealthPlanPackage(stateServer) + + const result = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: { + contractID: contractDraft.id, + updatedRates: [ + { + type: 'LINK', + rateID: foreignRateID, + }, + ], + }, + }, + }) + + expect(result.errors).toBeUndefined() + if (!result.data) { + throw new Error('no result') + } + + const draftRates = + result.data.updateDraftContractRates.contract.draftRates + + expect(draftRates).toHaveLength(1) + + const rateID = draftRates[0].id + + const updateResult = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: { + contractID: contractDraft.id, + updatedRates: [ + { + type: 'UPDATE', + rateID: rateID, + 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: 'updatedratedoc1.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(updateResult.errors).toBeDefined() + if (!updateResult.errors) { + throw new Error('no result') + } + + expect(updateResult.errors[0].extensions?.code).toBe('BAD_USER_INPUT') + }) + + it('doesnt allow linking a DRAFT rate', async () => { + const stateServer = await constructTestPostgresServer() + const otherPackage = + await createAndUpdateTestHealthPlanPackage(stateServer) + + const otherFD = latestFormData(otherPackage) + const foreignRateID = otherFD.rateInfos[0].id + + const contractDraft = await createTestHealthPlanPackage(stateServer) + + const result = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: { + contractID: contractDraft.id, + updatedRates: [ + { + type: 'LINK', + rateID: foreignRateID, + }, + ], + }, + }, + }) + + expect(result.errors).toBeDefined() + if (!result.errors) { + throw new Error('no result') + } + + expect(result.errors[0].message).toContain( + 'Attempted to link a rate that has never been submitted' + ) + expect(result.errors[0].extensions?.code).toBe('BAD_USER_INPUT') + }) + + it('doesnt allow updating a non-child rate', async () => { + const stateServer = await constructTestPostgresServer() + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + }) + + const otherPackage = + await createAndSubmitTestHealthPlanPackage(stateServer) + + await unlockTestHealthPlanPackage( + cmsServer, + otherPackage.id, + 'unlock to not update' + ) + + const otherFD = latestFormData(otherPackage) + const foreignRateID = otherFD.rateInfos[0].id + + const contractDraft = await createTestHealthPlanPackage(stateServer) + + const linkResult = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: { + contractID: contractDraft.id, + updatedRates: [ + { + type: 'LINK', + rateID: foreignRateID, + }, + ], + }, + }, + }) + + expect(linkResult.errors).toBeUndefined() + + const result = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: { + contractID: contractDraft.id, + updatedRates: [ + { + type: 'UPDATE', + rateID: foreignRateID, + 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: 'updatedratedoc1.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).toBeDefined() + if (!result.errors) { + throw new Error('no result') + } + + expect(result.errors[0].message).toContain( + 'Attempted to update a rate that is not a DRAFT' + ) + expect(result.errors[0].extensions?.code).toBe('BAD_USER_INPUT') + // TODO: This test must be updated to account for CHILDREN + }) + it('allows unlinking another submitted rate', async () => { const stateServer = await constructTestPostgresServer() @@ -681,6 +1021,5 @@ describe('updateDraftContractRates', () => { ) }) - it.todo('doesnt allow updating a non-child') - it.todo('doesnt allow updating a submitted rate') + it.todo('allows updating a child unlocked rate') }) diff --git a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts index 84e2dd6745..0046437da8 100644 --- a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts +++ b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts @@ -151,6 +151,7 @@ function updateDraftContractRates( } if (rateUpdate.type === 'UPDATE') { + // rate must be in list of associated rates const knownRateIDX = knownRateIDs.indexOf(rateUpdate.rateID) if (knownRateIDX === -1) { const errmsg = @@ -162,6 +163,52 @@ function updateDraftContractRates( } knownRateIDs.splice(knownRateIDX, 1) + // rate must be an editable child + const rateToUpdate = draftRates.find( + (r) => r.id === rateUpdate.rateID + ) + if (!rateToUpdate) { + const errmsg = + 'Programming Error: this rate should exist, we had its ID: ' + + rateUpdate.rateID + logError('updateDraftContractRates', errmsg) + setErrorAttributesOnActiveSpan(errmsg, span) + throw new Error(errmsg) + } + + if (rateToUpdate.status !== 'DRAFT') { + // eventually, this will be enough to cancel this. But until we have unlock-rate, you can edit UNLOCKED children of this contract. + const errmsg = + 'Attempted to update a rate that is not a DRAFT: ' + + rateUpdate.rateID + logError('updateDraftContractRates', errmsg) + setErrorAttributesOnActiveSpan(errmsg, span) + throw new UserInputError(errmsg) + + // TODO: reenable this check once we figure out how to make the types returned by contractWithHistory express parenthood + // if (rateToUpdate.status !== 'UNLOCKED') { + // const errmsg = + // 'Attempted to update a rate that is not editable: ' + + // rateUpdate.rateID + // logError('updateDraftContractRates', errmsg) + // setErrorAttributesOnActiveSpan(errmsg, span) + // throw new UserInputError(errmsg) + // } + + // // determine if this rate is a child of this contract, in which case it's ok. + // const firstRevision = rateToUpdate.revisions[rateToUpdate.revisions.length - 1] + // const parentContractRev = firstRevision.contractRevisions[0] // not possible to submit a rate with multiple contracts in first go + + // if (parentContractRev.contract.id !== contract.id) { + // const errmsg = + // 'Attempted to update a rate that is not a child of this contract: ' + + // rateUpdate.rateID + // logError('updateDraftContractRates', errmsg) + // setErrorAttributesOnActiveSpan(errmsg, span) + // throw new UserInputError(errmsg) + // } + } + rateUpdates.update.push({ rateID: rateUpdate.rateID, formData: rateUpdate.formData, @@ -175,6 +222,37 @@ function updateDraftContractRates( continue } + // 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) + } + + 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) + } + // this is a new link, actually link them. rateUpdates.link.push({ rateID: rateUpdate.rateID }) }