diff --git a/services/app-api/src/launchDarkly/launchDarkly.ts b/services/app-api/src/launchDarkly/launchDarkly.ts index 2a871c23ec..2e645b85f0 100644 --- a/services/app-api/src/launchDarkly/launchDarkly.ts +++ b/services/app-api/src/launchDarkly/launchDarkly.ts @@ -11,14 +11,12 @@ import { logError } from '../logger' import { setErrorAttributesOnActiveSpan } from '../resolvers/attributeHelper' //Set up default feature flag values used to returned data -const defaultFeatureFlags: FeatureFlagSettings = featureFlagKeys.reduce( - (a, c) => { +const defaultFeatureFlags = (): FeatureFlagSettings => + featureFlagKeys.reduce((a, c) => { const flag = featureFlags[c].flag const defaultValue = featureFlags[c].defaultValue return Object.assign(a, { [flag]: defaultValue }) - }, - {} as FeatureFlagSettings -) + }, {} as FeatureFlagSettings) type LDService = { getFeatureFlag: ( @@ -50,7 +48,8 @@ function offlineLDService(): LDService { `No connection to LaunchDarkly, fallback to offlineLDService with default value for ${flag}`, context.span ) - return defaultFeatureFlags[flag] + const featureFlags = defaultFeatureFlags() + return featureFlags[flag] }, } } diff --git a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts index 5601b40fc6..c01208f53d 100644 --- a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts @@ -92,9 +92,8 @@ describe('findContract', () => { must(await submitRate(client, rate3.id, stateUser.id, '3.0 create')) // Now, find that contract and assert the history is what we expected - const threeContract = await findContractWithHistory( - client, - contractA.id + const threeContract = must( + await findContractWithHistory(client, contractA.id) ) if (threeContract instanceof Error) { throw threeContract @@ -114,7 +113,9 @@ describe('findContract', () => { must(await submitRate(client, rate2.id, stateUser.id, '2.1 remove')) // Now, find that contract and assert the history is what we expected - const twoContract = await findContractWithHistory(client, contractA.id) + const twoContract = must( + await findContractWithHistory(client, contractA.id) + ) if (twoContract instanceof Error) { throw twoContract } @@ -131,9 +132,8 @@ describe('findContract', () => { must(await submitRate(client, rate1.id, stateUser.id, '1.1 new name')) // Now, find that contract and assert the history is what we expected - const backAgainContract = await findContractWithHistory( - client, - contractA.id + const backAgainContract = must( + await findContractWithHistory(client, contractA.id) ) if (backAgainContract instanceof Error) { throw backAgainContract @@ -159,9 +159,8 @@ describe('findContract', () => { ) // Now, find that contract and assert the history is what we expected - let testingContract = await findContractWithHistory( - client, - contractA.id + let testingContract = must( + await findContractWithHistory(client, contractA.id) ) if (testingContract instanceof Error) { throw testingContract @@ -202,16 +201,17 @@ describe('findContract', () => { ) // Now, find that contract and assert the history is what we expected - testingContract = await findContractWithHistory(client, contractA.id) + testingContract = must( + await findContractWithHistory(client, contractA.id) + ) if (testingContract instanceof Error) { throw testingContract } expect(testingContract.revisions).toHaveLength(8) // Now, find that contract and assert the history is what we expected - const resultingContract = await findContractWithHistory( - client, - contractA.id + const resultingContract = must( + await findContractWithHistory(client, contractA.id) ) if (resultingContract instanceof Error) { throw resultingContract @@ -453,9 +453,8 @@ describe('findContract', () => { ) // Now, find that contract and assert the history is what we expected - const resultingContract = await findContractWithHistory( - client, - contractA.id + const resultingContract = must( + await findContractWithHistory(client, contractA.id) ) if (resultingContract instanceof Error) { throw resultingContract @@ -628,11 +627,7 @@ describe('findContract', () => { throw new Error('Should be impossible to submit twice in a row.') } - const res = await findContractWithHistory(client, contractA.id) - - if (res instanceof Error) { - throw res - } + const res = must(await findContractWithHistory(client, contractA.id)) const revisions = res.revisions.reverse() diff --git a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts index 3aa0e1934b..bb916a44b3 100644 --- a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts @@ -2,6 +2,7 @@ import { PrismaTransactionType } from '../prismaTypes' import { ContractType } from '../../domain-models/contractAndRates/contractAndRatesZodSchema' import { parseContractWithHistory } from '../../domain-models/contractAndRates/parseDomainData' import { updateInfoIncludeUpdater } from '../prismaHelpers' +import { NotFoundError } from '../storeError' // findContractWithHistory returns a ContractType with a full set of // ContractRevisions in reverse chronological order. Each revision is a change to this @@ -10,7 +11,7 @@ import { updateInfoIncludeUpdater } from '../prismaHelpers' async function findContractWithHistory( client: PrismaTransactionType, contractID: string -): Promise { +): Promise { try { const contract = await client.contractTable.findFirst({ where: { @@ -48,7 +49,7 @@ async function findContractWithHistory( if (!contract) { const err = `PRISMA ERROR: Cannot find contract with id: ${contractID}` console.error(err) - return new Error(err) + return new NotFoundError(err) } return parseContractWithHistory(contract) diff --git a/services/app-api/src/postgres/contractAndRates/index.ts b/services/app-api/src/postgres/contractAndRates/index.ts new file mode 100644 index 0000000000..a19f8dabd8 --- /dev/null +++ b/services/app-api/src/postgres/contractAndRates/index.ts @@ -0,0 +1,31 @@ +import { insertDraftContract, InsertContractArgsType } from './insertContract' +import { findContractWithHistory } from './findContractWithHistory' +import { findDraftContract } from './findDraftContract' +import { + contractFormDataToDomainModel, + convertUpdateInfoToDomainModel, + draftContractRevToDomainModel, + draftContractToDomainModel, + contractRevToDomainModel, + draftRatesToDomainModel, + ratesRevisionsToDomainModel, + contractWithHistoryToDomainModel, + getContractStatus, +} from './prismaToDomainModel' + +export { + insertDraftContract, + findContractWithHistory, + findDraftContract, + contractFormDataToDomainModel, + convertUpdateInfoToDomainModel, + draftContractRevToDomainModel, + draftContractToDomainModel, + contractRevToDomainModel, + draftRatesToDomainModel, + ratesRevisionsToDomainModel, + contractWithHistoryToDomainModel, + getContractStatus, +} + +export type { InsertContractArgsType } diff --git a/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.ts b/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.ts index ff75f9a27f..7a955cb56e 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaToDomainModel.ts @@ -215,6 +215,9 @@ function contractWithHistoryToDomainModel( continue } + // This initial entry is the first history record of this contract revision. + // Then the for loop with it's rateRevisions are additional history records for each change in rate revisions. + // This is why allRevisionSets could have more entries than contract revisions. const initialEntry: ContractRevisionSet = { contractRev, submitInfo: contractRev.submitInfo, @@ -225,6 +228,7 @@ function contractWithHistoryToDomainModel( allRevisionSets.push(initialEntry) let lastEntry = initialEntry + // Now we construct a revision history for each change in rate revisions. // go through every rate revision in the join table in time order and construct a revisionSet // with (or without) the new rate revision in it. for (const rateRev of contractRev.rateRevisions) { @@ -270,11 +274,9 @@ function contractWithHistoryToDomainModel( const revisions = contractRevToDomainModel(allRevisionSets).reverse() - const contractStatus = getContractStatus(contract.revisions) - return { id: contract.id, - status: contractStatus, + status: getContractStatus(contract.revisions), stateCode: contract.stateCode, stateNumber: contract.stateNumber, revisions: revisions, diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.test.ts b/services/app-api/src/postgres/contractAndRates/submitContract.test.ts index b4688f87eb..a6ed1034cd 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.test.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.test.ts @@ -6,6 +6,7 @@ import { insertDraftRate } from './insertRate' import { submitRate } from './submitRate' import { updateDraftRate } from './updateDraftRate' import { must, createInsertContractData } from '../../testHelpers' +import { NotFoundError } from '../storeError' describe('submitContract', () => { it('creates a submission from a draft', async () => { @@ -23,9 +24,13 @@ describe('submitContract', () => { }) // submitting before there's a draft should be an error - expect( - await submitContract(client, '1111', '1111', 'failed submit') - ).toBeInstanceOf(Error) + const submitError = await submitContract( + client, + '1111', + '1111', + 'failed submit' + ) + expect(submitError).toBeInstanceOf(NotFoundError) // create a draft contract const draftContractData = createInsertContractData({ @@ -61,15 +66,15 @@ describe('submitContract', () => { }) ) - // resubmitting should be an error - expect( - await submitContract( - client, - contractA.id, - stateUser.id, - 'initial submit' - ) - ).toBeInstanceOf(Error) + const resubmitStoreError = await submitContract( + client, + contractA.id, + stateUser.id, + 'initial submit' + ) + + // resubmitting should be a store error + expect(resubmitStoreError).toBeInstanceOf(NotFoundError) }) it('invalidates old revisions when new revisions are submitted', async () => { diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index 9987204109..e62153049d 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -1,6 +1,7 @@ import { PrismaClient } from '@prisma/client' import { ContractType } from '../../domain-models/contractAndRates/contractAndRatesZodSchema' import { findContractWithHistory } from './findContractWithHistory' +import { NotFoundError } from '../storeError' // Update the given revision // * invalidate relationships of previous revision @@ -10,7 +11,7 @@ async function submitContract( contractID: string, submittedByUserID: string, submitReason: string -): Promise { +): Promise { const groupTime = new Date() try { @@ -38,9 +39,11 @@ async function submitContract( }, }, }) + if (!currentRev) { - console.error('No Unsubmitted Rev!') - return new Error('cant find the current rev to submit') + const err = `PRISMA ERROR: Cannot find the current rev to submit with contract id: ${contractID}` + console.error(err) + return new NotFoundError(err) } const submittedRateRevisions = currentRev.draftRates.map( @@ -149,7 +152,7 @@ async function submitContract( return await findContractWithHistory(tx, contractID) }) } catch (err) { - console.error('SUBMITeeee PRISMA CONTRACT ERR', err) + console.error('SUBMIT PRISMA CONTRACT ERR', err) return err } } diff --git a/services/app-api/src/postgres/contractAndRates/unlockContract.ts b/services/app-api/src/postgres/contractAndRates/unlockContract.ts index 928d0dfc03..e579a2adb7 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockContract.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockContract.ts @@ -1,6 +1,7 @@ import { PrismaClient } from '@prisma/client' import { ContractType } from '../../domain-models/contractAndRates/contractAndRatesZodSchema' import { findContractWithHistory } from './findContractWithHistory' +import { NotFoundError } from '../storeError' // Unlock the given contract // * copy form data @@ -10,7 +11,7 @@ async function unlockContract( contractID: string, unlockedByUserID: string, unlockReason: string -): Promise { +): Promise { const groupTime = new Date() try { @@ -36,12 +37,9 @@ async function unlockContract( }, }) if (!currentRev) { - console.error( - 'Programming Error: cannot find the current revision to submit' - ) - return new Error( - 'Programming Error: cannot find the current revision to submit' - ) + const err = `PRISMA ERROR: Cannot find the current revision to unlock with contract id: ${contractID}` + console.error(err) + return new NotFoundError(err) } if (!currentRev.submitInfoID) { @@ -95,7 +93,7 @@ async function unlockContract( return findContractWithHistory(tx, contractID) }) } catch (err) { - console.error('SUBMIT PRISMA CONTRACT ERR', err) + console.error('UNLOCK PRISMA CONTRACT ERR', err) return err } } diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContract.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContract.ts index 73b20ebb71..79f50dccbf 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContract.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContract.ts @@ -6,6 +6,7 @@ import { } from '@prisma/client' import { ContractType } from '../../domain-models/contractAndRates/contractAndRatesZodSchema' import { findContractWithHistory } from './findContractWithHistory' +import { NotFoundError } from '../storeError' type UpdateContractArgsType = { populationCovered: PopulationCoverageType @@ -24,7 +25,7 @@ async function updateDraftContract( contractID: string, formData: UpdateContractArgsType, rateIDs: string[] -): Promise { +): Promise { try { // Given all the Rates associated with this draft, find the most recent submitted // rateRevision to update. @@ -35,8 +36,9 @@ async function updateDraftContract( }, }) if (!currentRev) { - console.error('No Draft Rev!') - return new Error('cant find a draft rev to submit') + const err = `PRISMA ERROR: Cannot find the current rev to update with contract id: ${contractID}` + console.error(err) + return new NotFoundError(err) } await client.contractRevisionTable.update({ @@ -67,7 +69,7 @@ async function updateDraftContract( return findContractWithHistory(client, contractID) } catch (err) { - console.error('SUBMIT PRISMA CONTRACT ERR', err) + console.error('UPDATE PRISMA CONTRACT ERR', err) return err } } diff --git a/services/app-api/src/postgres/index.ts b/services/app-api/src/postgres/index.ts index ec79d0fa33..734bfd32f5 100644 --- a/services/app-api/src/postgres/index.ts +++ b/services/app-api/src/postgres/index.ts @@ -3,5 +3,5 @@ export { InsertHealthPlanPackageArgsType } from './healthPlanPackage' export { InsertUserArgsType } from './user' export { NewPostgresStore, Store } from './postgresStore' export { NewPrismaClient } from './prismaClient' -export { isStoreError, StoreError } from './storeError' +export { isStoreError, StoreError, NotFoundError } from './storeError' export { findStatePrograms } from './state/findStatePrograms' diff --git a/services/app-api/src/postgres/postgresStore.ts b/services/app-api/src/postgres/postgresStore.ts index a5defc4649..34c3a0af6b 100644 --- a/services/app-api/src/postgres/postgresStore.ts +++ b/services/app-api/src/postgres/postgresStore.ts @@ -43,11 +43,16 @@ import { insertQuestionResponse, } from './questionResponse' import { findAllSupportedStates } from './state' -import { ContractType } from '../domain-models/contractAndRates/contractAndRatesZodSchema' +import { + ContractRevisionType, + ContractType, +} from '../domain-models/contractAndRates/contractAndRatesZodSchema' import { InsertContractArgsType, insertDraftContract, -} from './contractAndRates/insertContract' + findContractWithHistory, + findDraftContract, +} from './contractAndRates' type Store = { findPrograms: ( @@ -122,9 +127,20 @@ type Store = { user: StateUserType ) => Promise + /** + * Rates database refactor prisma functions + */ insertDraftContract: ( args: InsertContractArgsType ) => Promise + + findContractWithHistory: ( + contractID: string + ) => Promise + + findDraftContract: ( + contractID: string + ) => Promise } function NewPostgresStore(client: PrismaClient): Store { @@ -183,6 +199,9 @@ function NewPostgresStore(client: PrismaClient): Store { * Rates database refactor prisma functions */ insertDraftContract: (args) => insertDraftContract(client, args), + findContractWithHistory: (args) => + findContractWithHistory(client, args), + findDraftContract: (args) => findDraftContract(client, args), } } diff --git a/services/app-api/src/postgres/storeError.ts b/services/app-api/src/postgres/storeError.ts index 543fb05fd7..25d2b61aaa 100644 --- a/services/app-api/src/postgres/storeError.ts +++ b/services/app-api/src/postgres/storeError.ts @@ -11,6 +11,7 @@ const StoreErrorCodes = [ 'USER_FORMAT_ERROR', 'UNEXPECTED_EXCEPTION', 'WRONG_STATUS', + 'NOT_FOUND_ERROR', ] as const type StoreErrorCode = (typeof StoreErrorCodes)[number] // iterable union type @@ -53,12 +54,11 @@ const convertPrismaErrorToStoreError = (prismaErr: unknown): StoreError => { // An operation failed because it depends on one or more records // that were required but not found. - // This is also returned when the userID doesn't exist in trying to connect - // a user and some states if (prismaErr.code === 'P2025') { return { - code: 'INSERT_ERROR', - message: 'insert failed because required record not found', + code: 'NOT_FOUND_ERROR', + message: + 'An operation failed because it depends on one or more records that were required but not found.', } } @@ -90,4 +90,17 @@ const convertPrismaErrorToStoreError = (prismaErr: unknown): StoreError => { } } -export { StoreError, isStoreError, convertPrismaErrorToStoreError } +class NotFoundError extends Error { + constructor(message: string) { + super(message) + + Object.setPrototypeOf(this, NotFoundError.prototype) + } +} + +export { + NotFoundError, + StoreError, + isStoreError, + convertPrismaErrorToStoreError, +} diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 1f7f396fa6..91d0ee8f53 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -38,7 +38,10 @@ export function configureResolvers( DateTime: GraphQLDateTime, Query: { fetchCurrentUser: fetchCurrentUserResolver(), - fetchHealthPlanPackage: fetchHealthPlanPackageResolver(store), + fetchHealthPlanPackage: fetchHealthPlanPackageResolver( + store, + launchDarkly + ), indexHealthPlanPackages: indexHealthPlanPackagesResolver(store), indexUsers: indexUsersResolver(store), indexQuestions: indexQuestionsResolver(store), diff --git a/services/app-api/src/resolvers/healthPlanPackage/createHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/createHealthPlanPackage.ts index 10f5ebfcc2..37891d3b4c 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/createHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/createHealthPlanPackage.ts @@ -113,9 +113,7 @@ export function createHealthPlanPackageResolver( logSuccess('createHealthPlanPackage') setSuccessAttributesOnActiveSpan(span) - return { - pkg, - } + return { pkg } } else { const pkgResult = await store.insertHealthPlanPackage(insertArgs) if (isStoreError(pkgResult)) { diff --git a/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.test.ts b/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.test.ts index e5318d77e8..480123c9c2 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.test.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.test.ts @@ -9,6 +9,7 @@ import { resubmitTestHealthPlanPackage, } from '../../testHelpers/gqlHelpers' import { testCMSUser, testStateUser } from '../../testHelpers/userHelpers' +import { testLDService } from '../../testHelpers/launchDarklyHelpers' describe('fetchHealthPlanPackage', () => { const testUserCMS = testCMSUser() @@ -48,6 +49,10 @@ describe('fetchHealthPlanPackage', () => { throw subData } + // When not using tables, the protobuf ID is used to as the HPP id when inserting a new HPP in the tables. + // So HPP id and proto id are the same. + // Now that our form data is in postgres contract revision table, the ids are not the same. So this expect is + // removed when flag is on. expect(subData.id).toEqual(createdID) expect(subData.programIDs).toEqual([ '5c10fe9f-bec9-416f-a20c-718b152ad633', @@ -63,7 +68,7 @@ describe('fetchHealthPlanPackage', () => { ]) }) - it('returns nothing if the ID doesnt exist', async () => { + it('returns error if the ID doesnt exist', async () => { const server = await constructTestPostgresServer() // then see if we can fetch that same submission @@ -76,10 +81,17 @@ describe('fetchHealthPlanPackage', () => { variables: { input }, }) - expect(result.errors).toBeUndefined() + expect(result.errors).toBeDefined() + if (result.errors === undefined) { + throw new Error('annoying jest typing behavior') + } + expect(result.errors).toHaveLength(1) + const resultErr = result.errors[0] - const resultSub = result.data?.fetchHealthPlanPackage.pkg - expect(resultSub).toBeNull() + expect(resultErr?.message).toBe( + `Issue finding a package with id ${input.pkgID}. Message: Result was undefined.` + ) + expect(resultErr?.extensions?.code).toBe('NOT_FOUND') }) it('returns multiple submissions payload with multiple revisions', async () => { @@ -377,3 +389,79 @@ describe('fetchHealthPlanPackage', () => { } }) }) + +// Currently we cannot set up fetchHPP tests like createHPP because not all resolvers have been migrated yet. +// Once all resolvers are migrated and tests in this describe block mirrors the ones above, we can then use describe.each +describe('fetchHealthPlanPackage rates-db-refactor flag on tests', () => { + const mockLDService = testLDService({ 'rates-db-refactor': true }) + it('returns package with one revision', async () => { + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) + + // First, create a new submission + const stateSubmission = await createTestHealthPlanPackage(server) + + const createdID = stateSubmission.id + + // then see if we can fetch that same submission + const input = { + pkgID: createdID, + } + + const result = await server.executeOperation({ + query: FETCH_HEALTH_PLAN_PACKAGE, + variables: { input }, + }) + + expect(result.errors).toBeUndefined() + + const resultSub = result.data?.fetchHealthPlanPackage.pkg + expect(resultSub.id).toEqual(createdID) + expect(resultSub.revisions).toHaveLength(1) + + const revision = resultSub.revisions[0].node + + const subData = base64ToDomain(revision.formDataProto) + if (subData instanceof Error) { + throw subData + } + + // Expect the created revision and the fetchHPP revision are the same. + expect(subData.id).toEqual(stateSubmission.revisions[0].node.id) + + expect(subData.programIDs).toEqual([ + '5c10fe9f-bec9-416f-a20c-718b152ad633', + ]) + expect(subData.submissionDescription).toBe('A created submission') + expect(subData.documents).toEqual([]) + }) + + it('returns error if the ID doesnt exist', async () => { + const server = await constructTestPostgresServer({ + ldService: mockLDService, + }) + + // then see if we can fetch that same submission + const input = { + pkgID: 'BOGUS-ID', + } + + const result = await server.executeOperation({ + query: FETCH_HEALTH_PLAN_PACKAGE, + variables: { input }, + }) + + expect(result.errors).toBeDefined() + if (result.errors === undefined) { + throw new Error('annoying jest typing behavior') + } + expect(result.errors).toHaveLength(1) + const resultErr = result.errors[0] + + expect(resultErr?.message).toBe( + `Issue finding a contract with history with id ${input.pkgID}. Message: PRISMA ERROR: Cannot find contract with id: BOGUS-ID` + ) + expect(resultErr?.extensions?.code).toBe('NOT_FOUND') + }) +}) diff --git a/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.ts index 4955d41e7c..d0511de994 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/fetchHealthPlanPackage.ts @@ -5,6 +5,7 @@ import { isAdminUser, HealthPlanPackageType, packageStatus, + convertContractToUnlockedHealthPlanPackage, } from '../../domain-models' import { isHelpdeskUser } from '../../domain-models/user' import { QueryResolvers, State } from '../../gen/gqlServer' @@ -15,33 +16,128 @@ import { setResolverDetailsOnActiveSpan, setSuccessAttributesOnActiveSpan, } from '../attributeHelper' +import { LDService } from '../../launchDarkly/launchDarkly' +import { GraphQLError } from 'graphql/index' +import { NotFoundError } from '../../postgres' export function fetchHealthPlanPackageResolver( - store: Store + store: Store, + launchDarkly: LDService ): QueryResolvers['fetchHealthPlanPackage'] { return async (_parent, { input }, context) => { const { user, span } = context setResolverDetailsOnActiveSpan('fetchHealthPlanPackage', user, span) - // fetch from the store - const result = await store.findHealthPlanPackage(input.pkgID) - - if (isStoreError(result)) { - const errMessage = `Issue finding a package of type ${result.code}. Message: ${result.message}` - logError('fetchHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new Error(errMessage) - } - if (result === undefined) { - const errMessage = `Issue finding a package with id ${input.pkgID}. Message: Result was undefined ` - logError('fetchHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - return { - pkg: undefined, + const ratesDatabaseRefactor = await launchDarkly.getFeatureFlag( + context, + 'rates-db-refactor' + ) + + let pkg: HealthPlanPackageType + + // Here is where we flag finding health plan + if (ratesDatabaseRefactor) { + // Health plans can be in two states Draft and Submitted, and we have 2 postgres functions for each. + // findContractWithHistory gets all submitted revisions and findDraftContract gets just the one draft revision + // We don't have function that gets all revisions including draft. So we have to call both functions when a + // contract is DRAFT. + const contractWithHistory = await store.findContractWithHistory( + input.pkgID + ) + + if (contractWithHistory instanceof Error) { + const errMessage = `Issue finding a contract with history with id ${input.pkgID}. Message: ${contractWithHistory.message}` + logError('fetchHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + + if (contractWithHistory instanceof NotFoundError) { + throw new GraphQLError(errMessage, { + extensions: { + code: 'NOT_FOUND', + cause: 'DB_ERROR', + }, + }) + } + + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + if (contractWithHistory.status === 'DRAFT') { + const draftRevision = await store.findDraftContract(input.pkgID) + if (draftRevision instanceof Error) { + // If draft returns undefined we error because a draft submission should always have a draft revision. + const errMessage = `Issue finding a draft contract with id ${input.pkgID}. Message: ${draftRevision.message}` + logError('fetchHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + + if (draftRevision === undefined) { + const errMessage = `Issue finding a draft contract with id ${input.pkgID}. Message: Result was undefined.` + logError('fetchHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'NOT_FOUND', + cause: 'DB_ERROR', + }, + }) + } + + // Pushing in the draft revision, so it would be first in the array of revisions. + contractWithHistory.revisions.push(draftRevision) } - } - const pkg: HealthPlanPackageType = result + const convertedPkg = + convertContractToUnlockedHealthPlanPackage(contractWithHistory) + + if (convertedPkg instanceof Error) { + const errMessage = `Issue converting contract. Message: ${convertedPkg.message}` + logError('fetchHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'PROTO_DECODE_ERROR', + }, + }) + } + + pkg = convertedPkg + } else { + const result = await store.findHealthPlanPackage(input.pkgID) + + if (isStoreError(result)) { + const errMessage = `Issue finding a package of type ${result.code}. Message: ${result.message}` + logError('fetchHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new Error(errMessage) + } + + if (result === undefined) { + const errMessage = `Issue finding a package with id ${input.pkgID}. Message: Result was undefined.` + logError('fetchHealthPlanPackage', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + throw new GraphQLError(errMessage, { + extensions: { + code: 'NOT_FOUND', + cause: 'DB_ERROR', + }, + }) + } + + pkg = result + } // Authorization CMS users can view, state users can only view if the state matches if (isStateUser(context.user)) { diff --git a/services/app-api/src/resolvers/questionResponse/createQuestionResponse.test.ts b/services/app-api/src/resolvers/questionResponse/createQuestionResponse.test.ts index abd4eee846..a82028ba8d 100644 --- a/services/app-api/src/resolvers/questionResponse/createQuestionResponse.test.ts +++ b/services/app-api/src/resolvers/questionResponse/createQuestionResponse.test.ts @@ -79,7 +79,7 @@ describe('createQuestionResponse', () => { expect(createdResponse.errors).toBeDefined() expect(assertAnErrorCode(createdResponse)).toBe('BAD_USER_INPUT') expect(assertAnError(createdResponse).message).toBe( - `Issue creating question response for question ${fakeID} of type INSERT_ERROR. Message: insert failed because required record not found` + `Issue creating question response for question ${fakeID} of type NOT_FOUND_ERROR. Message: An operation failed because it depends on one or more records that were required but not found.` ) }) diff --git a/services/app-api/src/resolvers/user/updateCMSUser.ts b/services/app-api/src/resolvers/user/updateCMSUser.ts index f819483e01..23c59c977f 100644 --- a/services/app-api/src/resolvers/user/updateCMSUser.ts +++ b/services/app-api/src/resolvers/user/updateCMSUser.ts @@ -106,7 +106,7 @@ export function updateCMSUserResolver( 'Updated user assignments' // someday might have a note field and make this a param ) if (isStoreError(result)) { - if (result.code === 'INSERT_ERROR') { + if (result.code === 'NOT_FOUND_ERROR') { const errMsg = 'cmsUserID does not exist' logError('updateCmsUser', errMsg) setErrorAttributesOnActiveSpan(errMsg, span) diff --git a/services/app-api/src/testHelpers/errorHelpers.ts b/services/app-api/src/testHelpers/errorHelpers.ts index 30275a9b56..505d93ac1a 100644 --- a/services/app-api/src/testHelpers/errorHelpers.ts +++ b/services/app-api/src/testHelpers/errorHelpers.ts @@ -1,8 +1,14 @@ // For use in TESTS only. Throws a returned error -function must(maybeErr: T | Error): T { +import { isStoreError, StoreError } from '../postgres' + +function must(maybeErr: T | Error | StoreError): T { if (maybeErr instanceof Error) { throw maybeErr } + + if (isStoreError(maybeErr)) { + throw maybeErr + } return maybeErr } diff --git a/services/app-api/src/testHelpers/launchDarklyHelpers.ts b/services/app-api/src/testHelpers/launchDarklyHelpers.ts index 10e7228288..2d7b4d497f 100644 --- a/services/app-api/src/testHelpers/launchDarklyHelpers.ts +++ b/services/app-api/src/testHelpers/launchDarklyHelpers.ts @@ -7,7 +7,7 @@ import { import { defaultFeatureFlags } from '../launchDarkly/launchDarkly' function testLDService(mockFeatureFlags?: FeatureFlagSettings): LDService { - const featureFlags = defaultFeatureFlags + const featureFlags = defaultFeatureFlags() //Update featureFlags with mock flag values. if (mockFeatureFlags) { diff --git a/services/app-api/src/testHelpers/storeHelpers.ts b/services/app-api/src/testHelpers/storeHelpers.ts index fc3811c7a2..88a12605d2 100644 --- a/services/app-api/src/testHelpers/storeHelpers.ts +++ b/services/app-api/src/testHelpers/storeHelpers.ts @@ -101,6 +101,16 @@ function mockStoreThatErrors(): Store { 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' ) }, + findContractWithHistory: async (_ID) => { + return new Error( + 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' + ) + }, + findDraftContract: async (_ID) => { + return new Error( + 'UNEXPECTED_EXCEPTION: This error came from the generic store with errors mock' + ) + }, } } diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index e61739561b..4740511a71 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -264,7 +264,7 @@ input FetchHealthPlanPackageInput { type FetchHealthPlanPackagePayload { "A single HealthPlanPackage" - pkg: HealthPlanPackage + pkg: HealthPlanPackage! } type HealthPlanPackageEdge { diff --git a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.test.tsx b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.test.tsx index ec58313c78..f029eca5b0 100644 --- a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.test.tsx +++ b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.test.tsx @@ -488,7 +488,7 @@ describe('StateSubmissionForm', () => { }) }) - it('shows a generic 404 page when package is undefined', async () => { + it('shows a generic 404 page when package is not found', async () => { renderWithProviders( { if (fetchResult.status === 'ERROR') { const err = fetchResult.error console.error('Error from API fetch', fetchResult.error) + if (err instanceof ApolloError) { handleApolloError(err, true) - } else { - recordJSException(err) + if (err.graphQLErrors[0]?.extensions?.code === 'NOT_FOUND') { + return + } } + + recordJSException(err) return // api failure or protobuf decode failure } const { data, revisionsLookup } = fetchResult const pkg = data.fetchHealthPlanPackage.pkg - // fetchHPP returns null if no package is found with the given ID - if (!pkg) { - return - } - // pull out the latest revision and document lookups const latestRevision = pkg.revisions[0].node const formDataFromLatestRevision = diff --git a/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummary.tsx b/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummary.tsx index 691e7a1645..3ce975b62b 100644 --- a/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummary.tsx +++ b/services/app-web/src/pages/SubmissionRevisionSummary/SubmissionRevisionSummary.tsx @@ -54,20 +54,19 @@ export const SubmissionRevisionSummary = (): React.ReactElement => { console.error('Error from API fetch', fetchResult.error) if (err instanceof ApolloError) { handleApolloError(err, true) - } else { - recordJSException(err) + + if (err.graphQLErrors[0]?.extensions?.code === 'NOT_FOUND') { + return + } } + + recordJSException(err) return // api failure or protobuf decode failure } const { data, revisionsLookup, documentDates } = fetchResult const pkg = data.fetchHealthPlanPackage.pkg - // fetchHPP returns null if no package is found with the given ID - if (!pkg) { - return - } - //We offset version by +1 of index, remove offset to find revision in revisions const revisionIndex = Number(revisionVersion) - 1 //Reversing revisions to get correct submission order diff --git a/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.test.tsx b/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.test.tsx index b6b3860627..ac9b7e42b4 100644 --- a/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.test.tsx +++ b/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.test.tsx @@ -493,7 +493,7 @@ describe('SubmissionSideNav', () => { expect(await screen.findByText('System error')).toBeInTheDocument() }) - it('shows a generic 404 page when package is undefined', async () => { + it('shows a generic 404 page when package is not found', async () => { renderWithProviders( }> diff --git a/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.tsx b/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.tsx index cbcd0eae30..aac7193e37 100644 --- a/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.tsx +++ b/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.tsx @@ -75,9 +75,13 @@ export const SubmissionSideNav = () => { console.error('Error from API fetch', fetchResult.error) if (err instanceof ApolloError) { handleApolloError(err, true) - } else { - recordJSException(err) + + if (err.graphQLErrors[0]?.extensions?.code === 'NOT_FOUND') { + return + } } + + recordJSException(err) return // api failure or protobuf decode failure } @@ -97,10 +101,6 @@ export const SubmissionSideNav = () => { return } - // fetchHPP with questions returns null if no package or questions is found with the given ID - if (!pkg) { - return - } const submissionStatus = pkg.status const isCMSUser = loggedInUser?.role === 'CMS_USER' diff --git a/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts b/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts index 60f2ec88b8..9702f12d68 100644 --- a/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/healthPlanPackageGQLMock.ts @@ -63,17 +63,22 @@ const fetchHealthPlanPackageMockSuccess = ({ const fetchHealthPlanPackageMockNotFound = ({ id, }: fetchHealthPlanPackageMockProps): MockedResponse => { + const graphQLError = new GraphQLError( + 'Issue finding a package with id a6039ed6-39cc-4814-8eaa-0c99f25e325d. Message: Result was undefined.', + { + extensions: { + code: 'NOT_FOUND', + }, + } + ) + return { request: { query: FetchHealthPlanPackageDocument, variables: { input: { pkgID: id } }, }, result: { - data: { - fetchHealthPlanPackage: { - pkg: undefined, - }, - }, + errors: [graphQLError], }, } } diff --git a/services/app-web/src/testHelpers/apolloMocks/questionResponseGQLMock.ts b/services/app-web/src/testHelpers/apolloMocks/questionResponseGQLMock.ts index 326f8a9613..797985f20d 100644 --- a/services/app-web/src/testHelpers/apolloMocks/questionResponseGQLMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/questionResponseGQLMock.ts @@ -13,6 +13,7 @@ import { } from '../../gen/gqlClient' import { mockValidCMSUser } from './userGQLMock' import { mockSubmittedHealthPlanPackage, mockQuestionsPayload } from './' +import { GraphQLError } from 'graphql' type fetchStateHealthPlanPackageWithQuestionsProps = { stateSubmission?: HealthPlanPackage | Partial @@ -109,17 +110,22 @@ const fetchStateHealthPlanPackageWithQuestionsMockSuccess = ({ const fetchStateHealthPlanPackageWithQuestionsMockNotFound = ({ id, }: fetchStateHealthPlanPackageWithQuestionsProps): MockedResponse => { + const graphQLError = new GraphQLError( + 'Issue finding a package with id a6039ed6-39cc-4814-8eaa-0c99f25e325d. Message: Result was undefined.', + { + extensions: { + code: 'NOT_FOUND', + }, + } + ) + return { request: { query: FetchHealthPlanPackageWithQuestionsDocument, variables: { input: { pkgID: id } }, }, result: { - data: { - fetchHealthPlanPackage: { - pkg: undefined, - }, - }, + errors: [graphQLError], }, } }