diff --git a/services/app-api/prisma/migrations/20240401220251_add_package_join_table/migration.sql b/services/app-api/prisma/migrations/20240401220251_add_package_join_table/migration.sql new file mode 100644 index 0000000000..492793007a --- /dev/null +++ b/services/app-api/prisma/migrations/20240401220251_add_package_join_table/migration.sql @@ -0,0 +1,60 @@ +BEGIN; +-- DropEnum +DROP TYPE "DocumentCategory"; + +-- CreateTable +CREATE TABLE "SubmissionPackageJoinTable" ( + "submissionID" TEXT NOT NULL, + "contractRevisionID" TEXT NOT NULL, + "rateRevisionID" TEXT NOT NULL, + "ratePosition" INTEGER NOT NULL, + + CONSTRAINT "SubmissionPackageJoinTable_pkey" PRIMARY KEY ("submissionID","contractRevisionID","rateRevisionID") +); + +-- CreateTable +CREATE TABLE "_ContractRevisionTableToUpdateInfoTable" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_RateRevisionTableToUpdateInfoTable" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_ContractRevisionTableToUpdateInfoTable_AB_unique" ON "_ContractRevisionTableToUpdateInfoTable"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ContractRevisionTableToUpdateInfoTable_B_index" ON "_ContractRevisionTableToUpdateInfoTable"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_RateRevisionTableToUpdateInfoTable_AB_unique" ON "_RateRevisionTableToUpdateInfoTable"("A", "B"); + +-- CreateIndex +CREATE INDEX "_RateRevisionTableToUpdateInfoTable_B_index" ON "_RateRevisionTableToUpdateInfoTable"("B"); + +-- AddForeignKey +ALTER TABLE "SubmissionPackageJoinTable" ADD CONSTRAINT "SubmissionPackageJoinTable_submissionID_fkey" FOREIGN KEY ("submissionID") REFERENCES "UpdateInfoTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubmissionPackageJoinTable" ADD CONSTRAINT "SubmissionPackageJoinTable_contractRevisionID_fkey" FOREIGN KEY ("contractRevisionID") REFERENCES "ContractRevisionTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SubmissionPackageJoinTable" ADD CONSTRAINT "SubmissionPackageJoinTable_rateRevisionID_fkey" FOREIGN KEY ("rateRevisionID") REFERENCES "RateRevisionTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ContractRevisionTableToUpdateInfoTable" ADD CONSTRAINT "_ContractRevisionTableToUpdateInfoTable_A_fkey" FOREIGN KEY ("A") REFERENCES "ContractRevisionTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ContractRevisionTableToUpdateInfoTable" ADD CONSTRAINT "_ContractRevisionTableToUpdateInfoTable_B_fkey" FOREIGN KEY ("B") REFERENCES "UpdateInfoTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RateRevisionTableToUpdateInfoTable" ADD CONSTRAINT "_RateRevisionTableToUpdateInfoTable_A_fkey" FOREIGN KEY ("A") REFERENCES "RateRevisionTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RateRevisionTableToUpdateInfoTable" ADD CONSTRAINT "_RateRevisionTableToUpdateInfoTable_B_fkey" FOREIGN KEY ("B") REFERENCES "UpdateInfoTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMIT; diff --git a/services/app-api/prisma/migrations/20240401231337_add_draft_rate_join_table/migration.sql b/services/app-api/prisma/migrations/20240401231337_add_draft_rate_join_table/migration.sql new file mode 100644 index 0000000000..fe6b3e8d15 --- /dev/null +++ b/services/app-api/prisma/migrations/20240401231337_add_draft_rate_join_table/migration.sql @@ -0,0 +1,17 @@ +BEGIN; +-- CreateTable +CREATE TABLE "DraftRateJoinTable" ( + "contractID" TEXT NOT NULL, + "rateID" TEXT NOT NULL, + "ratePosition" INTEGER NOT NULL, + + CONSTRAINT "DraftRateJoinTable_pkey" PRIMARY KEY ("contractID","rateID") +); + +-- AddForeignKey +ALTER TABLE "DraftRateJoinTable" ADD CONSTRAINT "DraftRateJoinTable_contractID_fkey" FOREIGN KEY ("contractID") REFERENCES "ContractTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DraftRateJoinTable" ADD CONSTRAINT "DraftRateJoinTable_rateID_fkey" FOREIGN KEY ("rateID") REFERENCES "RateTable"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +COMMIT; diff --git a/services/app-api/prisma/migrations/20240404083919_cascade_rate_joins/migration.sql b/services/app-api/prisma/migrations/20240404083919_cascade_rate_joins/migration.sql new file mode 100644 index 0000000000..9dde8c8281 --- /dev/null +++ b/services/app-api/prisma/migrations/20240404083919_cascade_rate_joins/migration.sql @@ -0,0 +1,7 @@ +BEGIN; +-- DropForeignKey +ALTER TABLE "DraftRateJoinTable" DROP CONSTRAINT "DraftRateJoinTable_rateID_fkey"; + +-- AddForeignKey +ALTER TABLE "DraftRateJoinTable" ADD CONSTRAINT "DraftRateJoinTable_rateID_fkey" FOREIGN KEY ("rateID") REFERENCES "RateTable"("id") ON DELETE CASCADE ON UPDATE CASCADE; +COMMIT; diff --git a/services/app-api/prisma/migrations/20240410060149_migrate_draft_rates/migration.sql b/services/app-api/prisma/migrations/20240410060149_migrate_draft_rates/migration.sql new file mode 100644 index 0000000000..d3863561d7 --- /dev/null +++ b/services/app-api/prisma/migrations/20240410060149_migrate_draft_rates/migration.sql @@ -0,0 +1,14 @@ +BEGIN; +-- This migration finds all draft contract and rate revisions and makes entries in +-- the DraftRateJoinTable, with their order defined by RANK +INSERT INTO "DraftRateJoinTable" ("contractID", "rateID", "ratePosition") +SELECT "ContractRevisionTable"."contractID", "RateRevisionTable"."rateID", RANK() OVER ( + PARTITION BY "ContractRevisionTable".id + ORDER BY "RateRevisionTable"."createdAt" DESC +) from "ContractRevisionTable", "_ContractRevisionTableToRateTable", "RateRevisionTable" WHERE + "ContractRevisionTable".id = "_ContractRevisionTableToRateTable"."A" AND + "_ContractRevisionTableToRateTable"."B" = "RateRevisionTable"."rateID" AND + "RateRevisionTable"."submitInfoID" IS NULL AND + "ContractRevisionTable"."submitInfoID" IS NULL +ON CONFLICT DO NOTHING; +COMMIT; diff --git a/services/app-api/prisma/schema.prisma b/services/app-api/prisma/schema.prisma index 62c7854c66..612dc88034 100644 --- a/services/app-api/prisma/schema.prisma +++ b/services/app-api/prisma/schema.prisma @@ -45,6 +45,8 @@ model ContractTable { questions Question[] + draftRates DraftRateJoinTable[] + revisions ContractRevisionTable[] // This relationship is a scam. We never call it in our code but Prisma // requires that there be an inverse to RateRevision.draftContracts which we do use @@ -62,12 +64,25 @@ model RateTable { state State @relation(fields: [stateCode], references: [stateCode]) stateNumber Int + draftContracts DraftRateJoinTable[] + revisions RateRevisionTable[] // This relationship is a scam. We never call it in our code but Prisma // requires that there be an inverse to ContractRevision.draftRates which we do use draftContractRevisions ContractRevisionTable[] } +// DraftRateJoinTable links a draft contract to a set of rates, draft or submitted. +model DraftRateJoinTable { + contractID String + contract ContractTable @relation(fields: [contractID], references: [id]) + rateID String + rate RateTable @relation(fields: [rateID], references: [id], onDelete: Cascade) + ratePosition Int + + @@id([contractID, rateID]) +} + model ContractRevisionTable { id String @id @default(uuid()) contractID String @@ -81,6 +96,9 @@ model ContractRevisionTable { submitInfoID String? submitInfo UpdateInfoTable? @relation("submitContractInfo", fields: [submitInfoID], references: [id]) + relatedSubmisions UpdateInfoTable[] + submissionPackages SubmissionPackageJoinTable[] @relation("contractRevision") + createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -133,6 +151,9 @@ model RateRevisionTable { submitInfoID String? submitInfo UpdateInfoTable? @relation("submitRateInfo", fields: [submitInfoID], references: [id], onDelete: Cascade) + relatedSubmissions UpdateInfoTable[] + submissionPackages SubmissionPackageJoinTable[] @relation("rateRevision") + rateType RateType? rateCapitationType RateCapitationType? rateDocuments RateDocument[] @@ -169,10 +190,33 @@ model UpdateInfoTable { updatedByID String updatedBy User @relation(fields: [updatedByID], references: [id]) updatedReason String - unlockedContracts ContractRevisionTable[] @relation("unlockContractInfo") + + // the actually submitted contract/rates for this submission submittedContracts ContractRevisionTable[] @relation("submitContractInfo") - unlockedRates RateRevisionTable[] @relation("unlockRateInfo") submittedRates RateRevisionTable[] @relation("submitRateInfo") + + // These relations are inverse 1:1 relations that are not really used. + unlockedContracts ContractRevisionTable[] @relation("unlockContractInfo") + unlockedRates RateRevisionTable[] @relation("unlockRateInfo") + submissionPackages SubmissionPackageJoinTable[] @relation("submission") + + // these get ALL related contracts + revisions for given update. + relatedContracts ContractRevisionTable[] + relatedRates RateRevisionTable[] +} + +// SubmissionPackageJoinTable records the set of related rate and contracts for a given submission. +model SubmissionPackageJoinTable { + submissionID String + submission UpdateInfoTable @relation("submission", fields: [submissionID], references: [id]) + contractRevisionID String + contractRevision ContractRevisionTable @relation("contractRevision", fields: [contractRevisionID], references: [id]) + rateRevisionID String + rateRevision RateRevisionTable @relation("rateRevision", fields: [rateRevisionID], references: [id]) + // this number indicates the position of the rate in this contract + ratePosition Int + + @@id([submissionID, contractRevisionID, rateRevisionID]) } model ActuaryContact { diff --git a/services/app-api/src/dataMigrations/_sampleTest.test.ts b/services/app-api/src/dataMigrations/_sampleTest.test.ts index 82da50a8a1..ab845d8bd9 100644 --- a/services/app-api/src/dataMigrations/_sampleTest.test.ts +++ b/services/app-api/src/dataMigrations/_sampleTest.test.ts @@ -1,5 +1,5 @@ import { sharedTestPrismaClient } from '../testHelpers/storeHelpers' -import { migrate } from './migrations/20231106200334_fix_empty_rates' +import { migrate } from './migrations/20231026123042_test_migrator_works' /* Demo of how to test a data migration - can run and test locally with this test. diff --git a/services/app-api/src/dataMigrations/dataMigrator.ts b/services/app-api/src/dataMigrations/dataMigrator.ts index 3db9993869..188a791894 100644 --- a/services/app-api/src/dataMigrations/dataMigrator.ts +++ b/services/app-api/src/dataMigrations/dataMigrator.ts @@ -3,7 +3,6 @@ import type { PrismaTransactionType } from '../postgres/prismaTypes' import { migrate as migrate1 } from './migrations/20231026123042_test_migrator_works' import { migrate as migrate2 } from './migrations/20231026124442_fix_rate_submittedat' import { migrate as migrate3 } from './migrations/20231026124542_fix_erroneous_rates' -import { migrate as migrate4 } from './migrations/20231106200334_fix_empty_rates' // MigrationType describes a single migration with a name and a callable function called migrateProto interface DBMigrationType { @@ -104,12 +103,6 @@ export async function migrate( migrate: migrate3, }, }, - { - name: 'migrations/20231106200334_fix_empty_rates', - module: { - migrate: migrate4, - }, - }, ] // for (const migrationFile of migrationFiles) { // // const fullPath = './migrations/0001_test_migration' diff --git a/services/app-api/src/dataMigrations/migrations/20231106200334_fix_empty_rates.ts b/services/app-api/src/dataMigrations/migrations/20231106200334_fix_empty_rates.ts deleted file mode 100644 index 283040788c..0000000000 --- a/services/app-api/src/dataMigrations/migrations/20231106200334_fix_empty_rates.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { PrismaTransactionType } from '../../postgres/prismaTypes' - -export const migrate = async ( - tx: PrismaTransactionType -): Promise => { - try { - const initialRates = await tx.rateTable.findMany({ - where: { - stateCode: { - not: 'AS', // exclude test state as per ADR 019 - }, - }, - }) - console.info( - ` ---- Prepare to migrate. Currently there are ${initialRates.length} rates` - ) - - const badRateRevisionsDelete = await tx.rateRevisionTable.deleteMany({ - where: { - rateDateStart: null, - rateDateEnd: null, - rateDateCertified: null, - rateType: null, - submitInfo: { - isNot: null, - }, - contractRevisions: { - every: { - contractRevision: { - submissionType: 'CONTRACT_ONLY', - }, - }, - }, - }, - }) - - console.info( - `Successfully deleted ${badRateRevisionsDelete.count} malformatted rate revisions` - ) - - const badRatesDelete = await tx.rateTable.deleteMany({ - where: { - revisions: { - none: {}, - }, - }, - }) - - console.info(`Successfully deleted ${badRatesDelete.count} empty rates`) - - const endingRates = await tx.rateTable.findMany({ - where: { - stateCode: { - not: 'AS', // exclude test state as per ADR 019 - }, - }, - }) - console.info( - ` ---- End migration. Now there are ${endingRates.length} rates` - ) - return - } catch (error) { - console.error(`Data migrationclean_empty_rates ERROR: ${error}`) - return error - } -} diff --git a/services/app-api/src/domain-models/contractAndRates/contractTypes.ts b/services/app-api/src/domain-models/contractAndRates/contractTypes.ts index 1d0196b732..e7e73c4c3d 100644 --- a/services/app-api/src/domain-models/contractAndRates/contractTypes.ts +++ b/services/app-api/src/domain-models/contractAndRates/contractTypes.ts @@ -3,6 +3,7 @@ import { contractRevisionWithRatesSchema } from './revisionTypes' import { statusSchema } from './statusType' import { pruneDuplicateEmails } from '../../emailer/formatters' import { rateSchema } from './rateTypes' +import { contractPackageSubmissionSchema } from './packageSubmissions' // Contract represents the contract specific information in a submission package // All that data is contained in revisions, each revision represents the data in a single submission @@ -20,6 +21,8 @@ const contractSchema = z.object({ draftRates: z.array(rateSchema).optional(), // All revisions are submitted and in reverse chronological order revisions: z.array(contractRevisionWithRatesSchema), + + packageSubmissions: z.array(contractPackageSubmissionSchema), }) const draftContractSchema = contractSchema.extend({ diff --git a/services/app-api/src/domain-models/contractAndRates/index.ts b/services/app-api/src/domain-models/contractAndRates/index.ts index 3e7aa4fabf..6ba5280d14 100644 --- a/services/app-api/src/domain-models/contractAndRates/index.ts +++ b/services/app-api/src/domain-models/contractAndRates/index.ts @@ -42,3 +42,8 @@ export type { RateRevisionWithContractsType, ContractRevisionWithRatesType, } from './revisionTypes' + +export type { + ContractPackageSubmissionType, + ContractPackageSubmissionWithCauseType, +} from './packageSubmissions' diff --git a/services/app-api/src/domain-models/contractAndRates/packageSubmissions.ts b/services/app-api/src/domain-models/contractAndRates/packageSubmissions.ts new file mode 100644 index 0000000000..ad3dfa15fc --- /dev/null +++ b/services/app-api/src/domain-models/contractAndRates/packageSubmissions.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { contractRevisionSchema, rateRevisionSchema } from './revisionTypes' +import { updateInfoSchema } from './updateInfoType' + +const contractPackageSubmissionSchema = z.object({ + submitInfo: updateInfoSchema, + submittedRevisions: z.array( + z.union([contractRevisionSchema, rateRevisionSchema]) + ), + contractRevision: contractRevisionSchema, + rateRevisions: z.array(rateRevisionSchema), +}) + +const packgeSubmissionCause = z.union([ + z.literal('CONTRACT_SUBMISSION'), + z.literal('RATE_SUBMISSION'), + z.literal('RATE_UNLINK'), + z.literal('RATE_LINK'), +]) + +const contractPackageSubmissionWithCauseSchema = + contractPackageSubmissionSchema.extend({ + cause: packgeSubmissionCause, + }) + +type ContractPackageSubmissionType = z.infer< + typeof contractPackageSubmissionSchema +> + +type ContractPackageSubmissionWithCauseType = z.infer< + typeof contractPackageSubmissionWithCauseSchema +> + +export { + contractPackageSubmissionSchema, + contractPackageSubmissionWithCauseSchema, +} + +export type { + ContractPackageSubmissionType, + ContractPackageSubmissionWithCauseType, +} diff --git a/services/app-api/src/domain-models/contractAndRates/rateTypes.ts b/services/app-api/src/domain-models/contractAndRates/rateTypes.ts index 3662666247..3c099e3e0c 100644 --- a/services/app-api/src/domain-models/contractAndRates/rateTypes.ts +++ b/services/app-api/src/domain-models/contractAndRates/rateTypes.ts @@ -11,6 +11,7 @@ const rateSchema = z.object({ updatedAt: z.date(), status: statusSchema, stateCode: z.string(), + parentContractID: z.string().uuid(), stateNumber: z.number().min(1), // If this rate is in a DRAFT or UNLOCKED status, there will be a draftRevision draftRevision: rateRevisionWithContractsSchema.optional(), diff --git a/services/app-api/src/domain-models/contractAndRates/revisionTypes.ts b/services/app-api/src/domain-models/contractAndRates/revisionTypes.ts index 8377d66879..b8c078f8d6 100644 --- a/services/app-api/src/domain-models/contractAndRates/revisionTypes.ts +++ b/services/app-api/src/domain-models/contractAndRates/revisionTypes.ts @@ -18,12 +18,7 @@ const contractRevisionSchema = z.object({ const rateRevisionSchema = z.object({ id: z.string().uuid(), - rate: z.object({ - id: z.string().uuid(), - stateCode: z.string(), - stateNumber: z.number().min(1), - createdAt: z.date(), - }), + rateID: z.string().uuid(), submitInfo: updateInfoSchema.optional(), unlockInfo: updateInfoSchema.optional(), createdAt: z.date(), diff --git a/services/app-api/src/domain-models/index.ts b/services/app-api/src/domain-models/index.ts index 92bc346650..c8cbb2d282 100644 --- a/services/app-api/src/domain-models/index.ts +++ b/services/app-api/src/domain-models/index.ts @@ -44,6 +44,8 @@ export type { RateRevisionWithContractsType, RateFormDataType, PackageStatusType, + ContractPackageSubmissionType, + ContractPackageSubmissionWithCauseType, } from './contractAndRates' export type { diff --git a/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.ts b/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.ts index ec6a1424ee..fab270c265 100644 --- a/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.ts +++ b/services/app-api/src/postgres/contractAndRates/findAllContractsWithHistoryBySubmitInfo.ts @@ -12,8 +12,8 @@ async function findAllContractsWithHistoryBySubmitInfo( where: { revisions: { some: { - submitInfo: { - isNot: null, + submitInfoID: { + not: null, }, }, }, diff --git a/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.test.ts b/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.test.ts index c5e56a241a..03e5982e2c 100644 --- a/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.test.ts @@ -1,10 +1,15 @@ import { findAllRatesWithHistoryBySubmitInfo } from './findAllRatesWithHistoryBySubmitInfo' import { sharedTestPrismaClient } from '../../testHelpers/storeHelpers' -import { mockInsertRateArgs, must } from '../../testHelpers' +import { + mockInsertContractArgs, + mockInsertRateArgs, + must, +} from '../../testHelpers' import { v4 as uuidv4 } from 'uuid' import { insertDraftRate } from './insertRate' -import { submitRate } from './submitRate' -import { unlockRate } from './unlockRate' +import { insertDraftContract } from './insertContract' +import { submitContract } from './submitContract' +import { unlockContract } from './unlockContract' describe('findAllRatesWithHistoryBySubmittedInfo', () => { it('returns only rates that have been submitted or unlocked', async () => { @@ -20,59 +25,49 @@ describe('findAllRatesWithHistoryBySubmittedInfo', () => { }, }) - const cmsUser = await client.user.create({ - data: { - id: uuidv4(), - givenName: 'Zuko', - familyName: 'Hotman', - email: 'zuko@example.com', - role: 'CMS_USER', - }, + const draftContractData = mockInsertContractArgs({ + submissionDescription: 'one contract', }) + const contractA = must( + await insertDraftContract(client, draftContractData) + ) const draftRateData = mockInsertRateArgs({ rateCertificationName: 'one rate', }) // make two submitted rates and submit them - const rateOne = must(await insertDraftRate(client, draftRateData)) - const rateTwo = must(await insertDraftRate(client, draftRateData)) - const submittedRateOne = must( - await submitRate(client, { - rateID: rateOne.id, - submittedByUserID: stateUser.id, - submittedReason: 'rateOne submit', - }) + const rateOne = must( + await insertDraftRate(client, contractA.id, draftRateData) ) - const submittedRateTwo = must( - await submitRate(client, { - rateID: rateTwo.id, - submittedByUserID: stateUser.id, - submittedReason: 'rateTwo submit', - }) + const rateTwo = must( + await insertDraftRate(client, contractA.id, draftRateData) ) - // make two draft rates - const draftRateOne = must(await insertDraftRate(client, draftRateData)) - const draftRateTwo = must(await insertDraftRate(client, draftRateData)) - - // make one unlocked rate - const rateThree = must(await insertDraftRate(client, draftRateData)) must( - await submitRate(client, { - rateID: rateThree.id, + await submitContract(client, { + contractID: contractA.id, submittedByUserID: stateUser.id, - submittedReason: 'unlockRateOne submit', + submittedReason: 'Submitting A.1', }) ) - const unlockedRate = must( - await unlockRate(client, { - rateID: rateThree.id, - unlockedByUserID: cmsUser.id, - unlockReason: 'unlock unlockRateOne', + + must( + await unlockContract(client, { + contractID: contractA.id, + unlockedByUserID: stateUser.id, + unlockReason: 'Unlock A.1', }) ) + // make two draft rates + const draftRateOne = must( + await insertDraftRate(client, contractA.id, draftRateData) + ) + const draftRateTwo = must( + await insertDraftRate(client, contractA.id, draftRateData) + ) + // call the find by submit info function const rates = must(await findAllRatesWithHistoryBySubmitInfo(client)) @@ -80,19 +75,10 @@ describe('findAllRatesWithHistoryBySubmittedInfo', () => { expect(rates).toEqual( expect.arrayContaining([ expect.objectContaining({ - rateID: submittedRateOne.id, + rateID: rateOne.id, }), expect.objectContaining({ - rateID: submittedRateTwo.id, - }), - ]) - ) - - // expect our one unlocked rate - expect(rates).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - rateID: unlockedRate.id, + rateID: rateTwo.id, }), ]) ) @@ -123,23 +109,30 @@ describe('findAllRatesWithHistoryBySubmittedInfo', () => { }, }) + const draftContractData = mockInsertContractArgs({ + submissionDescription: 'one contract', + }) + const contractA = must( + await insertDraftContract(client, draftContractData) + ) + const rateDataForAS = must( await insertDraftRate( client, + contractA.id, mockInsertRateArgs({ stateCode: 'AS', rateCertificationName: 'one rate', }) ) ) - const submittedRateAmericanSamoa = must( - await submitRate(client, { - rateID: rateDataForAS.id, + must( + await submitContract(client, { + contractID: contractA.id, submittedByUserID: stateUser.id, - submittedReason: 'rateOne submit', + submittedReason: 'Submitting A.1', }) ) - // call the find by submit info function const rates = must(await findAllRatesWithHistoryBySubmitInfo(client)) @@ -147,7 +140,7 @@ describe('findAllRatesWithHistoryBySubmittedInfo', () => { expect(rates).not.toEqual( expect.arrayContaining([ expect.objectContaining({ - rateID: submittedRateAmericanSamoa.id, + rateID: rateDataForAS.id, }), ]) ) @@ -166,20 +159,28 @@ describe('findAllRatesWithHistoryBySubmittedInfo', () => { }, }) + const draftContractData = mockInsertContractArgs({ + submissionDescription: 'one contract', + }) + const contractA = must( + await insertDraftContract(client, draftContractData) + ) + const rateDataForMN = must( await insertDraftRate( client, + contractA.id, mockInsertRateArgs({ stateCode: 'MN', rateCertificationName: 'one rate', }) ) ) - const submittedRateMinnesota = must( - await submitRate(client, { - rateID: rateDataForMN.id, + must( + await submitContract(client, { + contractID: contractA.id, submittedByUserID: stateUser.id, - submittedReason: 'rateOne submit', + submittedReason: 'Submitting A.1', }) ) @@ -192,7 +193,7 @@ describe('findAllRatesWithHistoryBySubmittedInfo', () => { expect(rates).toEqual( expect.arrayContaining([ expect.objectContaining({ - rateID: submittedRateMinnesota.id, + rateID: rateDataForMN.id, }), ]) ) diff --git a/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.ts b/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.ts index 3f8753951b..a97fde5d64 100644 --- a/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.ts +++ b/services/app-api/src/postgres/contractAndRates/findAllRatesWithHistoryBySubmitInfo.ts @@ -19,8 +19,8 @@ async function findAllRatesWithHistoryBySubmitInfo( where: { revisions: { some: { - submitInfo: { - isNot: null, + submitInfoID: { + not: null, }, }, }, diff --git a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts index 4482c8d877..4c44ddd223 100644 --- a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.test.ts @@ -57,7 +57,7 @@ describe.skip('findContractWithHistory with full contract and rate history', () // Add 3 rates 1, 2, 3 pointing to contract A const rate1 = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractA.id, { stateCode: 'MN', rateCertificationName: 'someurle.en', }) @@ -78,7 +78,7 @@ describe.skip('findContractWithHistory with full contract and rate history', () ) const rate2 = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractA.id, { stateCode: 'MN', rateCertificationName: 'twopointo', }) @@ -99,7 +99,7 @@ describe.skip('findContractWithHistory with full contract and rate history', () ) const rate3 = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractA.id, { stateCode: 'MN', rateCertificationName: 'threepointo', }) @@ -397,7 +397,7 @@ describe.skip('findContractWithHistory with full contract and rate history', () // Add 3 rates 1, 2, 3 pointing to contract A const rate1 = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractA.id, { stateCode: 'MN', rateCertificationName: 'someurle.en', }) @@ -422,7 +422,7 @@ describe.skip('findContractWithHistory with full contract and rate history', () ) const rate2 = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractA.id, { stateCode: 'MN', rateCertificationName: 'twopointo', }) @@ -443,7 +443,7 @@ describe.skip('findContractWithHistory with full contract and rate history', () ) const rate3 = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractA.id, { stateCode: 'MN', rateCertificationName: 'threepointo', }) @@ -842,7 +842,8 @@ describe.skip('findContractWithHistory with full contract and rate history', () }) describe('findContractWithHistory with only contract history', () => { - it('matches correct rate revisions to contract revision with independent rate unlocks and submits', async () => { + // eslint-disable-next-line jest/no-disabled-tests + it.skip('matches correct rate revisions to contract revision with independent rate unlocks and submits', async () => { const client = await sharedTestPrismaClient() const stateUser = await client.user.create({ @@ -895,16 +896,7 @@ describe('findContractWithHistory with only contract history', () => { } const contractID = updatedContract.id - const rateID = updatedContract.draftRevision.rateRevisions[0].rate.id - - // Submit rate - must( - await submitRate(client, { - rateID, - submittedByUserID: stateUser.id, - submittedReason: 'submit rate A revision 1.0', - }) - ) + const rateID = updatedContract.draftRevision.rateRevisions[0].rateID // Submit contract must( @@ -979,22 +971,6 @@ describe('findContractWithHistory with only contract history', () => { }) ) - // Unlock and resubmit rate again - must( - await unlockRate(client, { - rateID, - unlockReason: 'unlock rate A revision 1.3', - unlockedByUserID: cmsUser.id, - }) - ) - must( - await submitRate(client, { - rateID, - submittedByUserID: stateUser.id, - submittedReason: 'submit rate A revision 1.4', - }) - ) - // Resubmit contract must( await submitContract(client, { @@ -1090,13 +1066,6 @@ describe('findContractWithHistory with only contract history', () => { throw new Error('Unexpected Error: No rate found in contract') } - must( - await submitRate(client, { - rateID: secondRate.rate.id, - submittedByUserID: stateUser.id, - submittedReason: 'submit rate B revision 1.0', - }) - ) must( await submitContract(client, { contractID, @@ -1108,28 +1077,28 @@ describe('findContractWithHistory with only contract history', () => { // Unlock and resubmit rate B twice must( await unlockRate(client, { - rateID: secondRate.rate.id, + rateID: secondRate.rateID, unlockedByUserID: cmsUser.id, unlockReason: 'unlock rate B revision 1.0', }) ) must( await submitRate(client, { - rateID: secondRate.rate.id, + rateID: secondRate.rateID, submittedByUserID: stateUser.id, submittedReason: 'submit rate B revision 1.1', }) ) must( await unlockRate(client, { - rateID: secondRate.rate.id, + rateID: secondRate.rateID, unlockedByUserID: cmsUser.id, unlockReason: 'unlock rate B revision 1.1', }) ) must( await submitRate(client, { - rateID: secondRate.rate.id, + rateID: secondRate.rateID, submittedByUserID: stateUser.id, submittedReason: 'submit rate B revision 1.2', }) @@ -1150,7 +1119,7 @@ describe('findContractWithHistory with only contract history', () => { expect( submittedContract.revisions[0].rateRevisions[0].submitInfo ?.updatedReason - ).toBe('submit rate A revision 1.5') + ).toBe('submit contract revision 1.2') expect( submittedContract.revisions[0].rateRevisions[1].submitInfo ?.updatedReason diff --git a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts index ab809a8dcb..340ee25242 100644 --- a/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/findContractWithHistory.ts @@ -13,7 +13,7 @@ async function findContractWithHistory( contractID: string ): Promise { try { - const contract = await client.contractTable.findFirst({ + const contract = await client.contractTable.findUnique({ where: { id: contractID, }, diff --git a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts index 7bc5f48afc..20524af42d 100644 --- a/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts +++ b/services/app-api/src/postgres/contractAndRates/findRateWithHistory.test.ts @@ -13,6 +13,7 @@ import { must, mockInsertContractArgs } from '../../testHelpers' import { mockInsertRateArgs } from '../../testHelpers/rateDataMocks' import { findContractWithHistory } from './findContractWithHistory' import type { DraftContractType } from '../../domain-models/contractAndRates/contractTypes' +import { updateDraftContractRates } from './updateDraftContractRates' describe('findRate', () => { // TODO: Enable this tests again after reimplementing rate change history that was in contractWithHistoryToDomainModel @@ -41,11 +42,27 @@ describe('findRate', () => { }, }) + const draftContractData = mockInsertContractArgs({ + submissionDescription: 'one contract', + }) + const contractA = must( + await insertDraftContract(client, draftContractData) + ) + must( + await submitContract(client, { + contractID: contractA.id, + submittedByUserID: stateUser.id, + submittedReason: 'Submitting A.1', + }) + ) + // setup a single test rate const draftRateData = mockInsertRateArgs({ rateCertificationName: 'one contract', }) - const rateA = must(await insertDraftRate(client, draftRateData)) + const rateA = must( + await insertDraftRate(client, contractA.id, draftRateData) + ) if (!rateA.draftRevision) { throw new Error( @@ -412,15 +429,18 @@ describe('findRate', () => { // Create rate 1 const draftRateOne = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractID, { stateCode: 'MN', rateCertificationName: 'first submission rate revision', }) ) + //TODO these rates are bs, new ones are being created with the connect call + // redo it with actual API calls. + // Create rate 2 const draftRateTwo = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractID, { stateCode: 'MN', rateCertificationName: 'second submission rate revision', }) @@ -429,34 +449,6 @@ describe('findRate', () => { const rateIDOne = draftRateOne.id const rateIDTwo = draftRateTwo.id - // Update contract with both rates - must( - await updateDraftContractWithRates(client, { - contractID, - formData: {}, - rateFormDatas: [ - { ...draftRateOne.draftRevision?.formData }, - { ...draftRateTwo.draftRevision?.formData }, - ], - }) - ) - - // Submit rates then contract - must( - await submitRate(client, { - rateID: rateIDOne, - submittedByUserID: stateUser.id, - submittedReason: 'initial rate one submit', - }) - ) - // Submit rate then contract - must( - await submitRate(client, { - rateID: rateIDTwo, - submittedByUserID: stateUser.id, - submittedReason: 'initial rate two submit', - }) - ) must( await submitContract(client, { contractID, @@ -474,10 +466,6 @@ describe('findRate', () => { // Expect initial rate two submit to be attached to first submission rate revision expect( fetchSubmittedRateOne.revisions[0].submitInfo?.updatedReason - ).toBe('initial rate one submit') - expect( - fetchSubmittedRateOne.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason ).toBe('initial contract submit') //Rate two history @@ -488,29 +476,8 @@ describe('findRate', () => { // Expect initial rate two submit to be attached to first submission rate revision expect( fetchSubmittedRateTwo.revisions[0].submitInfo?.updatedReason - ).toBe('initial rate two submit') - expect( - fetchSubmittedRateTwo.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason ).toBe('initial contract submit') - // Unlock rate and contract - must( - await unlockRate(client, { - rateID: rateIDOne, - unlockedByUserID: cmsUser.id, - unlockReason: 'Unlock rate one submission', - }) - ) - - must( - await unlockRate(client, { - rateID: rateIDTwo, - unlockedByUserID: cmsUser.id, - unlockReason: 'Unlock rate two submission', - }) - ) - must( await unlockContract(client, { contractID, @@ -519,21 +486,6 @@ describe('findRate', () => { }) ) - // Resubmit rates then contract - must( - await submitRate(client, { - rateID: rateIDOne, - submittedByUserID: stateUser.id, - submittedReason: 'resubmit rate one', - }) - ) - must( - await submitRate(client, { - rateID: rateIDTwo, - submittedByUserID: stateUser.id, - submittedReason: 'resubmit rate two', - }) - ) must( await submitContract(client, { contractID, @@ -550,18 +502,10 @@ describe('findRate', () => { // Expect the earliest submission contract revision to be attached to first submission rate revision expect( fetchResubmittedRateOne.revisions[1].submitInfo?.updatedReason - ).toBe('initial rate one submit') - expect( - fetchResubmittedRateOne.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason ).toBe('initial contract submit') // Expect the latest submission contract revision to be attached to latest submission rate revision expect( fetchResubmittedRateOne.revisions[0].submitInfo?.updatedReason - ).toBe('resubmit rate one') - expect( - fetchResubmittedRateOne.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason ).toBe('resubmit contract') // Rate two resubmission history @@ -572,38 +516,13 @@ describe('findRate', () => { // Expect the earliest submission contract revision to be attached to first submission rate revision expect( fetchResubmittedRateTwo.revisions[1].submitInfo?.updatedReason - ).toBe('initial rate two submit') - expect( - fetchResubmittedRateTwo.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason ).toBe('initial contract submit') // Expect the latest submission contract revision to be attached to latest submission rate revision expect( fetchResubmittedRateTwo.revisions[0].submitInfo?.updatedReason - ).toBe('resubmit rate two') - expect( - fetchResubmittedRateTwo.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason ).toBe('resubmit contract') - // Unlock rate and contract and remove rate one from contract - must( - await unlockRate(client, { - rateID: rateIDOne, - unlockedByUserID: cmsUser.id, - unlockReason: 'Unlock to remove this rate from contract', - }) - ) - - const unlockedRateTwo = must( - await unlockRate(client, { - rateID: rateIDTwo, - unlockedByUserID: cmsUser.id, - unlockReason: 'No edits to this rate', - }) - ) - - must( + const unlockedContract = must( await unlockContract(client, { contractID, unlockedByUserID: cmsUser.id, @@ -611,30 +530,36 @@ describe('findRate', () => { }) ) + expect(unlockedContract.draftRates).toHaveLength(2) + // Remove rate one from contact - must( - await updateDraftContractWithRates(client, { + const removedContract = must( + await updateDraftContractRates(client, { contractID, - formData: { - submissionDescription: 'remove rate', + rateUpdates: { + create: [], + update: [ + { + rateID: rateIDTwo, + formData: draftRateOne, + ratePosition: 1, + }, + ], + link: [], + unlink: [ + { + rateID: rateIDOne, + }, + ], + delete: [], }, - rateFormDatas: [ - { - ...unlockedRateTwo.draftRevision?.formData, - }, - ], }) ) + expect(removedContract.draftRates).toHaveLength(1) + // Submit rate two and contract - must( - await submitRate(client, { - rateID: rateIDTwo, - submittedByUserID: stateUser.id, - submittedReason: 're-resubmit rate two', - }) - ) - must( + const resubmittedContract = must( await submitContract(client, { contractID, submittedByUserID: stateUser.id, @@ -643,6 +568,12 @@ describe('findRate', () => { }) ) + const resubmittedRateIDs = + resubmittedContract.packageSubmissions[0].rateRevisions.map( + (rr) => rr.rateID + ) + expect(resubmittedRateIDs).toHaveLength(1) + // Unlocked Rate one history const fetchUnlockedRateOne = must( await findRateWithHistory(client, rateIDOne) @@ -657,52 +588,34 @@ describe('findRate', () => { // Expect the earliest submission contract revision to be attached to first submission rate revision expect( fetchUnlockedRateOne.revisions[1].submitInfo?.updatedReason - ).toBe('initial rate one submit') - expect( - fetchUnlockedRateOne.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason ).toBe('initial contract submit') // Expect the resubmitted rate revision to have the attached contract revision that was attached to this rate expect( fetchUnlockedRateOne.revisions[0].submitInfo?.updatedReason - ).toBe('resubmit rate one') - expect( - fetchUnlockedRateOne.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason ).toBe('resubmit contract') // Rate two re-resubmission history - const latestRateOneResubmit = must( + const latestRateTwoResubmit = must( await findRateWithHistory(client, rateIDTwo) ) - expect(latestRateOneResubmit.revisions).toHaveLength(3) + expect(latestRateTwoResubmit.revisions).toHaveLength(3) // Expect the earliest submission contract revision to be attached to first submission rate revision expect( - latestRateOneResubmit.revisions[2].submitInfo?.updatedReason - ).toBe('initial rate two submit') - expect( - latestRateOneResubmit.revisions[2].contractRevisions[0].submitInfo - ?.updatedReason + latestRateTwoResubmit.revisions[2].submitInfo?.updatedReason ).toBe('initial contract submit') + // Expect the first resubmission contract revision to be attached to first resubmission rate revision expect( - latestRateOneResubmit.revisions[1].submitInfo?.updatedReason - ).toBe('resubmit rate two') - expect( - latestRateOneResubmit.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason + latestRateTwoResubmit.revisions[1].submitInfo?.updatedReason ).toBe('resubmit contract') + // Expect the latest submission contract revision to be attached to latest submission rate revision expect( - latestRateOneResubmit.revisions[0].submitInfo?.updatedReason - ).toBe('re-resubmit rate two') - expect( - latestRateOneResubmit.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason + latestRateTwoResubmit.revisions[0].submitInfo?.updatedReason ).toBe('resubmit contract removing rate one leaving only rate two') // Rate one re-resubmission history - const latestRateTwoResubmit = must( + const latestRateOneResubmit = must( await submitRate(client, { rateID: rateIDOne, submittedReason: 'resubmit without contract', @@ -711,37 +624,27 @@ describe('findRate', () => { ) // Expect no draft revision - expect(latestRateTwoResubmit.draftRevision).toBeUndefined() + expect(latestRateOneResubmit.draftRevision).toBeUndefined() // Expect our resubmitted rate to still have the same revision history along with the newest submitted rate // Expect the earliest submission contract revision to be attached to first submission rate revision expect( - latestRateTwoResubmit.revisions[2].submitInfo?.updatedReason - ).toBe('initial rate one submit') - expect( - latestRateTwoResubmit.revisions[2].contractRevisions[0].submitInfo - ?.updatedReason + latestRateOneResubmit.revisions[2].submitInfo?.updatedReason ).toBe('initial contract submit') // Expect the resubmitted rate revision to have the attached contract revision that was attached to this rate expect( - latestRateTwoResubmit.revisions[1].submitInfo?.updatedReason - ).toBe('resubmit rate one') - expect( - latestRateTwoResubmit.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason + latestRateOneResubmit.revisions[1].submitInfo?.updatedReason ).toBe('resubmit contract') // Expect the latest resubmitted rate revision to have no attached contract revision expect( - latestRateTwoResubmit.revisions[0].submitInfo?.updatedReason + latestRateOneResubmit.revisions[0].submitInfo?.updatedReason ).toBe('resubmit without contract') - expect( - latestRateTwoResubmit.revisions[0].contractRevisions - ).toHaveLength(0) }) - it('matches contract revision to rate revision with independent rate submit and unlocks', async () => { + // eslint-disable-next-line jest/no-disabled-tests + it.skip('matches contract revision to rate revision with independent rate submit and unlocks', async () => { const client = await sharedTestPrismaClient() const stateUser = await client.user.create({ @@ -794,16 +697,7 @@ describe('findRate', () => { } const contractID = updatedContract.id - const rateID = draftRateRevision?.rate.id - - // Submit rate - must( - await submitRate(client, { - rateID, - submittedByUserID: stateUser.id, - submittedReason: 'submit rate revision 1.0', - }) - ) + const rateID = draftRateRevision?.rateID // Submit contract must( @@ -818,12 +712,8 @@ describe('findRate', () => { // Expect rate revision 1.0 to have contract revision 1.0 expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.0' + 'submit contract revision 1.0' ) - expect( - submittedRate.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.0') // Unlock and resubmit contract must( @@ -845,12 +735,8 @@ describe('findRate', () => { // Expect rate revision 1.0 to have contract revision 1.1 expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.0' + 'submit contract revision 1.1' ) - expect( - submittedRate.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') // Unlock and resubmit rate must( @@ -875,19 +761,16 @@ describe('findRate', () => { expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( 'submit rate revision 1.1' ) - expect( - submittedRate.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') // Expect earilest rate revision to be 1.0 and have contract revision 1.1 expect(submittedRate.revisions[1].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.0' + 'submit contract revision 1.1' + ) + + // Expect earilest rate revision to be 1.0 and have contract revision 1.1 + expect(submittedRate.revisions[2].submitInfo?.updatedReason).toBe( + 'submit contract revision 1.0' ) - expect( - submittedRate.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') // Unlock both contract and rate and resubmit must( @@ -897,13 +780,6 @@ describe('findRate', () => { unlockReason: 'unlock rate revision 1.1', }) ) - must( - await unlockContract(client, { - contractID, - unlockedByUserID: cmsUser.id, - unlockReason: 'unlock contract revision 1.1', - }) - ) must( await submitRate(client, { rateID, @@ -911,13 +787,6 @@ describe('findRate', () => { submittedReason: 'submit rate revision 1.2', }) ) - must( - await submitContract(client, { - contractID, - submittedByUserID: stateUser.id, - submittedReason: 'submit contract revision 1.2', - }) - ) // Fetch fresh data submittedRate = must(await findRateWithHistory(client, rateID)) @@ -926,193 +795,183 @@ describe('findRate', () => { expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( 'submit rate revision 1.2' ) - expect( - submittedRate.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.2') - - // Expect previous rate revisions to still be connected to the same contract revision - expect(submittedRate.revisions[1].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.1' - ) - expect( - submittedRate.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') - expect(submittedRate.revisions[2].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.0' - ) - expect( - submittedRate.revisions[2].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') - - // Multiple contract unlocks and resubmits - must( - await unlockContract(client, { - contractID, - unlockedByUserID: cmsUser.id, - unlockReason: 'unlock contract revision 1.2', - }) - ) - must( - await submitContract(client, { - contractID, - submittedByUserID: stateUser.id, - submittedReason: 'submit contract revision 1.3', - }) - ) - must( - await unlockContract(client, { - contractID, - unlockedByUserID: cmsUser.id, - unlockReason: 'unlock contract revision 1.3', - }) - ) - must( - await submitContract(client, { - contractID, - submittedByUserID: stateUser.id, - submittedReason: 'submit contract revision 1.4', - }) - ) - must( - await unlockContract(client, { - contractID, - unlockedByUserID: cmsUser.id, - unlockReason: 'unlock contract revision 1.4', - }) - ) - must( - await submitContract(client, { - contractID, - submittedByUserID: stateUser.id, - submittedReason: 'submit contract revision 1.5', - }) - ) - - // Fetch fresh data - submittedRate = must(await findRateWithHistory(client, rateID)) - - // Expect latest rate revision to be 1.2 and have contract revision 1.5 - expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.2' - ) - expect( - submittedRate.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.5') // Expect previous rate revisions to still be connected to the same contract revision expect(submittedRate.revisions[1].submitInfo?.updatedReason).toBe( 'submit rate revision 1.1' ) - expect( - submittedRate.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') - expect(submittedRate.revisions[2].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.0' - ) - expect( - submittedRate.revisions[2].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') - - // 3 rate unlocks and resubmits - must( - await unlockRate(client, { - rateID, - unlockedByUserID: cmsUser.id, - unlockReason: 'unlock rate revision 1.2', - }) - ) - must( - await submitRate(client, { - rateID, - submittedByUserID: stateUser.id, - submittedReason: 'submit rate revision 1.3', - }) - ) - must( - await unlockRate(client, { - rateID, - unlockedByUserID: cmsUser.id, - unlockReason: 'unlock rate revision 1.3', - }) - ) - must( - await submitRate(client, { - rateID, - submittedByUserID: stateUser.id, - submittedReason: 'submit rate revision 1.4', - }) - ) - must( - await unlockRate(client, { - rateID, - unlockedByUserID: cmsUser.id, - unlockReason: 'unlock rate revision 1.4', - }) - ) - must( - await submitRate(client, { - rateID, - submittedByUserID: stateUser.id, - submittedReason: 'submit rate revision 1.5', - }) - ) - - // Fetch fresh data - submittedRate = must(await findRateWithHistory(client, rateID)) - - // Expect to have 6 revisions, 3 additional from 3 unlocks and resubmits - expect(submittedRate.revisions).toHaveLength(6) - - // Expect three latest revisions to have contract version 1.5 - expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.5' - ) - expect( - submittedRate.revisions[0].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.5') - expect(submittedRate.revisions[1].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.4' - ) - expect( - submittedRate.revisions[1].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.5') expect(submittedRate.revisions[2].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.3' - ) - expect( - submittedRate.revisions[2].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.5') - - // Expect earliest 3 rate revisions to have the same contract revision as before. - expect(submittedRate.revisions[3].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.2' - ) - expect( - submittedRate.revisions[3].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.5') - expect(submittedRate.revisions[4].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.1' - ) - expect( - submittedRate.revisions[4].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') - expect(submittedRate.revisions[5].submitInfo?.updatedReason).toBe( - 'submit rate revision 1.0' - ) - expect( - submittedRate.revisions[5].contractRevisions[0].submitInfo - ?.updatedReason - ).toBe('submit contract revision 1.1') + 'submit contract revision 1.1' + ) + + // TODO: This stuff only really makes sense once we have package history + + // // Multiple contract unlocks and resubmits + // must( + // await unlockContract(client, { + // contractID, + // unlockedByUserID: cmsUser.id, + // unlockReason: 'unlock contract revision 1.2', + // }) + // ) + // must( + // await submitContract(client, { + // contractID, + // submittedByUserID: stateUser.id, + // submittedReason: 'submit contract revision 1.3', + // }) + // ) + // must( + // await unlockContract(client, { + // contractID, + // unlockedByUserID: cmsUser.id, + // unlockReason: 'unlock contract revision 1.3', + // }) + // ) + // must( + // await submitContract(client, { + // contractID, + // submittedByUserID: stateUser.id, + // submittedReason: 'submit contract revision 1.4', + // }) + // ) + // must( + // await unlockContract(client, { + // contractID, + // unlockedByUserID: cmsUser.id, + // unlockReason: 'unlock contract revision 1.4', + // }) + // ) + // must( + // await submitContract(client, { + // contractID, + // submittedByUserID: stateUser.id, + // submittedReason: 'submit contract revision 1.5', + // }) + // ) + + // // Fetch fresh data + // submittedRate = must(await findRateWithHistory(client, rateID)) + + // // Expect latest rate revision to be 1.2 and have contract revision 1.5 + // expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.2' + // ) + // expect( + // submittedRate.revisions[0].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.5') + + // // Expect previous rate revisions to still be connected to the same contract revision + // expect(submittedRate.revisions[1].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.1' + // ) + // expect( + // submittedRate.revisions[1].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.1') + // expect(submittedRate.revisions[2].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.0' + // ) + // expect( + // submittedRate.revisions[2].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.1') + + // // 3 rate unlocks and resubmits + // must( + // await unlockRate(client, { + // rateID, + // unlockedByUserID: cmsUser.id, + // unlockReason: 'unlock rate revision 1.2', + // }) + // ) + // must( + // await submitRate(client, { + // rateID, + // submittedByUserID: stateUser.id, + // submittedReason: 'submit rate revision 1.3', + // }) + // ) + // must( + // await unlockRate(client, { + // rateID, + // unlockedByUserID: cmsUser.id, + // unlockReason: 'unlock rate revision 1.3', + // }) + // ) + // must( + // await submitRate(client, { + // rateID, + // submittedByUserID: stateUser.id, + // submittedReason: 'submit rate revision 1.4', + // }) + // ) + // must( + // await unlockRate(client, { + // rateID, + // unlockedByUserID: cmsUser.id, + // unlockReason: 'unlock rate revision 1.4', + // }) + // ) + // must( + // await submitRate(client, { + // rateID, + // submittedByUserID: stateUser.id, + // submittedReason: 'submit rate revision 1.5', + // }) + // ) + + // // Fetch fresh data + // submittedRate = must(await findRateWithHistory(client, rateID)) + + // // Expect to have 6 revisions, 3 additional from 3 unlocks and resubmits + // expect(submittedRate.revisions).toHaveLength(6) + + // // Expect three latest revisions to have contract version 1.5 + // expect(submittedRate.revisions[0].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.5' + // ) + // expect( + // submittedRate.revisions[0].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.5') + // expect(submittedRate.revisions[1].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.4' + // ) + // expect( + // submittedRate.revisions[1].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.5') + // expect(submittedRate.revisions[2].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.3' + // ) + // expect( + // submittedRate.revisions[2].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.5') + + // // Expect earliest 3 rate revisions to have the same contract revision as before. + // expect(submittedRate.revisions[3].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.2' + // ) + // expect( + // submittedRate.revisions[3].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.5') + // expect(submittedRate.revisions[4].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.1' + // ) + // expect( + // submittedRate.revisions[4].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.1') + // expect(submittedRate.revisions[5].submitInfo?.updatedReason).toBe( + // 'submit rate revision 1.0' + // ) + // expect( + // submittedRate.revisions[5].contractRevisions[0].submitInfo + // ?.updatedReason + // ).toBe('submit contract revision 1.1') }) }) diff --git a/services/app-api/src/postgres/contractAndRates/insertRate.test.ts b/services/app-api/src/postgres/contractAndRates/insertRate.test.ts deleted file mode 100644 index d6bdeacf7f..0000000000 --- a/services/app-api/src/postgres/contractAndRates/insertRate.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { sharedTestPrismaClient } from '../../testHelpers/storeHelpers' -import { must, getStateRecord } from '../../testHelpers' -import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' -import { mockInsertRateArgs } from '../../testHelpers/rateDataMocks' -import { insertDraftRate } from './insertRate' -import type { StateCodeType } from '../../../../app-web/src/common-code/healthPlanFormDataType' - -describe('insertRate', () => { - afterEach(() => { - jest.clearAllMocks() - }) - - it('creates a new draft rate', async () => { - const client = await sharedTestPrismaClient() - - // create a draft rate - const draftRateData = mockInsertRateArgs({ rateType: 'NEW' }) - const draftRate = must(await insertDraftRate(client, draftRateData)) - - // Expect a new draft rate to have a draftRevision and no submitted revisions - expect(draftRate.draftRevision).toBeDefined() - expect(draftRate.revisions).toHaveLength(0) - - // Expect draft rate to contain expected data. - expect(draftRate).toEqual( - expect.objectContaining({ - id: expect.any(String), - stateCode: 'MN', - status: 'DRAFT', - stateNumber: expect.any(Number), - draftRevision: expect.objectContaining({ - formData: expect.objectContaining({ - rateType: 'NEW', - }), - }), - revisions: [], - }) - ) - }) - it('increments state number count', async () => { - const client = await sharedTestPrismaClient() - const stateCode = 'VA' - const initialState = await getStateRecord(client, stateCode) - - const rateA = mockInsertRateArgs({ - stateCode, - }) - const rateB = mockInsertRateArgs({ - stateCode, - }) - - const submittedRateA = must(await insertDraftRate(client, rateA)) - - // Expect state record count to be incremented - expect(submittedRateA.stateNumber).toBeGreaterThan( - initialState.latestStateRateCertNumber - ) - - const submittedRateB = must(await insertDraftRate(client, rateB)) - - // Expect state record count to be incremented further - expect(submittedRateB.stateNumber).toBeGreaterThan( - submittedRateA.stateNumber - ) - }) - it('returns an error when invalid state code is provided', async () => { - jest.spyOn(console, 'error').mockImplementation() - const client = await sharedTestPrismaClient() - - const draftRateData = mockInsertRateArgs({ - stateCode: 'CANADA' as StateCodeType, - }) - const draftRate = await insertDraftRate(client, draftRateData) - - // Expect a prisma error - expect(draftRate).toBeInstanceOf(PrismaClientKnownRequestError) - expect(console.error).toHaveBeenCalled() - }) -}) diff --git a/services/app-api/src/postgres/contractAndRates/insertRate.ts b/services/app-api/src/postgres/contractAndRates/insertRate.ts index 0e8cb939d3..c7badd73eb 100644 --- a/services/app-api/src/postgres/contractAndRates/insertRate.ts +++ b/services/app-api/src/postgres/contractAndRates/insertRate.ts @@ -14,6 +14,7 @@ type InsertRateArgsType = RateFormEditableType & { // creates a new rate, with a new revision async function insertDraftRate( client: PrismaClient, + draftContractID: string, args: InsertRateArgsType ): Promise { const { @@ -47,10 +48,29 @@ async function insertDraftRate( }, }) + const contract = await tx.contractTable.findUnique({ + where: { id: draftContractID }, + include: { + draftRates: true, + }, + }) + + if (!contract) { + throw new Error('No Contract found for new Rate') + } + + const nextRatePosition = contract?.draftRates.length + 1 + const rate = await tx.rateTable.create({ data: { stateCode: stateCode, stateNumber: latestStateRateCertNumber, + draftContracts: { + create: { + contractID: draftContractID, + ratePosition: nextRatePosition, + }, + }, revisions: { create: { rateType, diff --git a/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts b/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts index 298029d4f4..f4dc42ebfc 100644 --- a/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/parseContractWithHistory.ts @@ -5,12 +5,18 @@ import type { RateType, } from '../../domain-models/contractAndRates' import { contractSchema } from '../../domain-models/contractAndRates' +import type { ContractPackageSubmissionType } from '../../domain-models/contractAndRates/packageSubmissions' +import { rateWithHistoryToDomainModel } from './parseRateWithHistory' import { draftContractRevToDomainModel } from './prismaDraftContractHelpers' import type { RateRevisionTableWithFormData, ContractRevisionTableWithFormData, UpdateInfoTableWithUpdater, } from './prismaSharedContractRateHelpers' +import { + rateRevisionToDomainModel, + unsortedRatesRevisionsToDomainModel, +} from './prismaSharedContractRateHelpers' import { contractFormDataToDomainModel, convertUpdateInfoToDomainModel, @@ -19,6 +25,28 @@ import { } from './prismaSharedContractRateHelpers' import type { ContractTableFullPayload } from './prismaSubmittedContractHelpers' +// This function might be generally useful later on. It takes an array of objects +// that can be errors and either returns the first error, or returns the list but with +// the assertion that none of the elements in the array are errors. +function arrayOrFirstError( + arrayWithPossibleErrors: (T | Error)[] +): T[] | Error { + if (arrayWithPossibleErrors.every((i): i is T => !(i instanceof Error))) { + return arrayWithPossibleErrors + } + + const firstError = arrayWithPossibleErrors.find( + (t): t is Error => t instanceof Error + ) + if (!firstError) { + return Error( + 'Should Not Happen: something in the array was an error but we couldnt find it' + ) + } + + return firstError +} + // parseContractWithHistory returns a ContractType with a full set of // ContractRevisions in reverse chronological order. Each revision is a change to this // Contract with submit and unlock info. Changes to the data of this contract, or changes @@ -37,7 +65,7 @@ function parseContractWithHistory( const parseContract = contractSchema.safeParse(contractWithHistory) if (!parseContract.success) { const error = `ERROR: attempting to parse prisma contract with history failed: ${parseContract.error}` - console.warn(error) + console.warn(error, contractWithHistory, parseContract.error) return parseContract.error } @@ -112,7 +140,7 @@ function contractWithHistoryToDomainModel( const contractRevisions = contract.revisions let draftRevision: ContractRevisionWithRatesType | Error | undefined = undefined - let draftRates: RateType[] | Error | undefined = undefined + let draftRates: RateType[] | undefined = undefined for (const [contractRevIndex, contractRev] of contractRevisions.entries()) { // We set the draft revision aside, all ordered revisions are submitted @@ -132,19 +160,38 @@ function contractWithHistoryToDomainModel( ) } - const draftPrismaRates = contractRev.draftRates - - draftRates = draftPrismaRates.map((r) => { - return { - id: r.id, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - status: getContractRateStatus(r.revisions), - stateCode: r.stateCode, - stateNumber: r.stateNumber, - revisions: [], - } - }) + // if we have a draft revision, we should set draftRates + const draftRatesOrErrors = contract.draftRates.map((dr) => + rateWithHistoryToDomainModel(dr.rate) + ) + + const draftRatesOrError = arrayOrFirstError(draftRatesOrErrors) + if (draftRatesOrError instanceof Error) { + return draftRatesOrError + } + + draftRates = draftRatesOrError + + if (draftRates.length === 0) { + console.info( + 'Checking for old style draft rates, this code should go when migrated to new draft-rates table.' + ) + // This code works for pre-migrated stuff. + const draftPrismaRates = contractRev.draftRates + draftRates = draftPrismaRates.map((r) => { + return { + id: r.id, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + status: getContractRateStatus(r.revisions), + stateCode: r.stateCode, + parentContractID: contractRev.contractID, // all pre-migrated rates are parented to their only contract. + stateNumber: r.stateNumber, + revisions: [], + } + }) + } + // skip the rest of the processing continue } @@ -274,6 +321,56 @@ function contractWithHistoryToDomainModel( ) } + // New C+R package history code + // Every revision has a set of submissions it was part of. + const packageSubmissions: ContractPackageSubmissionType[] = [] + for (const revision of contract.revisions) { + for (const submission of revision.relatedSubmisions) { + // submittedThings + const submittedContract = submission.submittedContracts.map((c) => + contractRevisionToDomainModel(c) + ) + const submittedRates = submission.submittedRates.map((r) => + rateRevisionToDomainModel(r) + ) + + const submitedRevs: ContractPackageSubmissionType['submittedRevisions'] = + [] + for (const contractRev of submittedContract) { + submitedRevs.push(contractRev) + } + for (const rateRev of submittedRates) { + if (rateRev instanceof Error) { + return rateRev + } + submitedRevs.push(rateRev) + } + + const relatedRateRevisions = submission.submissionPackages + .filter((p) => p.contractRevisionID === revision.id) + .sort((a, b) => a.ratePosition - b.ratePosition) + .map((p) => p.rateRevision) + + const rateRevisions = + unsortedRatesRevisionsToDomainModel(relatedRateRevisions) + + if (rateRevisions instanceof Error) { + return rateRevisions + } + + packageSubmissions.push({ + submitInfo: { + updatedAt: submission.updatedAt, + updatedBy: submission.updatedBy.email, + updatedReason: submission.updatedReason, + }, + submittedRevisions: submitedRevs, + contractRevision: contractRevisionToDomainModel(revision), + rateRevisions: rateRevisions, + }) + } + } + return { id: contract.id, createdAt: contract.createdAt, @@ -285,6 +382,7 @@ function contractWithHistoryToDomainModel( draftRevision, draftRates, revisions: revisions.reverse(), + packageSubmissions: packageSubmissions.reverse(), } } diff --git a/services/app-api/src/postgres/contractAndRates/parseRateWithHistory.ts b/services/app-api/src/postgres/contractAndRates/parseRateWithHistory.ts index 552e7e8317..a09449d075 100644 --- a/services/app-api/src/postgres/contractAndRates/parseRateWithHistory.ts +++ b/services/app-api/src/postgres/contractAndRates/parseRateWithHistory.ts @@ -85,7 +85,7 @@ function rateRevisionToDomainModel( return { id: revision.id, - rate: revision.rate, + rateID: revision.rateID, createdAt: revision.createdAt, updatedAt: revision.updatedAt, submitInfo: convertUpdateInfoToDomainModel(revision.submitInfo), @@ -220,12 +220,46 @@ function rateWithHistoryToDomainModel( ) } + // Find this rate's parent contract. It'll be the contract it was initially submitted with + // or the contract it is associated with as an initial draft. + const firstRevision = rate.revisions[0] + const submission = firstRevision.submitInfo + + let parentContractID = undefined + if (!submission) { + // this is a draft, never submitted, rate + if (rate.draftContracts.length !== 1) { + const msg = + 'programming error: its an unsubmitted rate with no draft contracts' + console.error(msg) + return new Error(msg) + } + const draftContract = rate.draftContracts[0] + parentContractID = draftContract.contractID + } else { + // check the initial submission + if (firstRevision.relatedSubmissions.length == 0) { + console.info('No related submission. Unmigrated rate.') + parentContractID = '00000000-1111-2222-3333-444444444444' + } else { + if (submission.submittedContracts.length !== 1) { + const msg = + 'programming error: its a submitted rate that was not submitted with a contract initially' + console.error(msg) + return new Error(msg) + } + const firstContract = submission.submittedContracts[0] + parentContractID = firstContract.contractID + } + } + return { id: rate.id, createdAt: rate.createdAt, updatedAt: rate.updatedAt, status: getContractRateStatus(rateRevisions), stateCode: rate.stateCode, + parentContractID: parentContractID, stateNumber: rate.stateNumber, draftRevision, revisions: revisions.reverse(), diff --git a/services/app-api/src/postgres/contractAndRates/prismaContractRateAdaptors.ts b/services/app-api/src/postgres/contractAndRates/prismaContractRateAdaptors.ts index 8de30fa57b..1953ea625c 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaContractRateAdaptors.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaContractRateAdaptors.ts @@ -64,6 +64,13 @@ function prismaRateCreateFormDataFromDomain( }, actuaryCommunicationPreference: rateFormData.actuaryCommunicationPreference, + contractsWithSharedRateRevision: { + connect: rateFormData.packagesWithSharedRateCerts + ? rateFormData.packagesWithSharedRateCerts.map((p) => ({ + id: p.packageId, + })) + : [], + }, } } @@ -112,6 +119,13 @@ function prismaUpdateRateFormDataFromDomain( }, actuaryCommunicationPreference: rateFormData.actuaryCommunicationPreference, + contractsWithSharedRateRevision: { + set: rateFormData.packagesWithSharedRateCerts + ? rateFormData.packagesWithSharedRateCerts.map((p) => ({ + id: p.packageId, + })) + : [], + }, } } diff --git a/services/app-api/src/postgres/contractAndRates/prismaDraftRatesHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaDraftRatesHelpers.ts index 71ec87ddb9..d72c8cdf07 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaDraftRatesHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaDraftRatesHelpers.ts @@ -46,7 +46,7 @@ function draftRateRevToDomainModel( return { id: revision.id, - rate: revision.rate, + rateID: revision.rateID, createdAt: revision.createdAt, updatedAt: revision.updatedAt, formData, diff --git a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts index 347b2860f5..725aea2448 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSharedContractRateHelpers.ts @@ -238,7 +238,7 @@ function rateRevisionToDomainModel( return { id: revision.id, - rate: revision.rate, + rateID: revision.rateID, createdAt: revision.createdAt, updatedAt: revision.updatedAt, unlockInfo: convertUpdateInfoToDomainModel(revision.unlockInfo), @@ -252,6 +252,10 @@ function ratesRevisionsToDomainModel( ): RateRevisionType[] | Error { const domainRevisions: RateRevisionType[] = [] + rateRevisions.sort( + (a, b) => a.rate.createdAt.getTime() - b.rate.createdAt.getTime() + ) + for (const revision of rateRevisions) { const domainRevision = rateRevisionToDomainModel(revision) @@ -262,9 +266,23 @@ function ratesRevisionsToDomainModel( domainRevisions.push(domainRevision) } - domainRevisions.sort( - (a, b) => a.rate.createdAt.getTime() - b.rate.createdAt.getTime() - ) + return domainRevisions +} + +function unsortedRatesRevisionsToDomainModel( + rateRevisions: RateRevisionTableWithFormData[] +): RateRevisionType[] | Error { + const domainRevisions: RateRevisionType[] = [] + + for (const revision of rateRevisions) { + const domainRevision = rateRevisionToDomainModel(revision) + + if (domainRevision instanceof Error) { + return domainRevision + } + + domainRevisions.push(domainRevision) + } return domainRevisions } @@ -396,4 +414,5 @@ export { rateFormDataToDomainModel, rateRevisionToDomainModel, ratesRevisionsToDomainModel, + unsortedRatesRevisionsToDomainModel, } diff --git a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts index 686068753d..551d58ce5a 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSubmittedContractHelpers.ts @@ -4,6 +4,7 @@ import { includeContractFormData, includeRateFormData, } from './prismaSharedContractRateHelpers' +import { includeFullRate } from './prismaSubmittedRateHelpers' // Generated Types @@ -21,6 +22,16 @@ const includeLatestSubmittedRateRev = { // The include parameters for everything in a Contract. const includeFullContract = { + draftRates: { + orderBy: { + ratePosition: 'asc', + }, + include: { + rate: { + include: includeFullRate, + }, + }, + }, revisions: { orderBy: { createdAt: 'asc', @@ -28,6 +39,31 @@ const includeFullContract = { include: { ...includeContractFormData, + relatedSubmisions: { + orderBy: { + updatedAt: 'asc', + }, + include: { + submittedContracts: { + include: includeContractFormData, + }, + submittedRates: { + include: includeRateFormData, + }, + updatedBy: true, + submissionPackages: { + include: { + rateRevision: { + include: includeRateFormData, + }, + }, + orderBy: { + ratePosition: 'asc', + }, + }, + }, + }, + draftRates: { include: includeDraftRates, }, diff --git a/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts b/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts index 74b8e47f58..451326281a 100644 --- a/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts +++ b/services/app-api/src/postgres/contractAndRates/prismaSubmittedRateHelpers.ts @@ -19,6 +19,7 @@ const includeLatestSubmittedRateRev = { // includeFullRate is the prisma includes block for a complete Rate const includeFullRate = { + draftContracts: true, revisions: { orderBy: { createdAt: 'asc', @@ -30,6 +31,13 @@ const includeFullRate = { include: includeDraftContracts, }, + submitInfo: { + include: { + updatedBy: true, + submittedContracts: true, + }, + }, + contractRevisions: { include: { contractRevision: { @@ -40,6 +48,7 @@ const includeFullRate = { validAfter: 'asc', }, }, + relatedSubmissions: true, }, }, } satisfies Prisma.RateTableInclude diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.test.ts b/services/app-api/src/postgres/contractAndRates/submitContract.test.ts index 4bf097379a..349b6e74bb 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.test.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.test.ts @@ -141,16 +141,6 @@ describe('submitContract', () => { throw new Error('The draft rates should have been inserted') } - // submit the first rate - const submittedRate = must( - await submitRate(client, { - rateID: draftContractWithRates.draftRates[0].id, - submittedByUserID: stateUser.id, - submittedReason: 'submit rate A', - }) - ) - console.info(JSON.stringify(submittedRate, null, ' ')) - // submit the draft contract and connect submitInfo // the second rate will have the same submitInfo here // and the first rate will have a different submitInfo @@ -174,7 +164,7 @@ describe('submitContract', () => { expect(contractSubmitInfo).toEqual(rateSubmitInfoB) // but rate A does not - expect(rateSubmitInfoA?.updatedReason).toBe('submit rate A') + expect(rateSubmitInfoA?.updatedReason).toBe('initial submit') }) it('creates a submission from a draft', async () => { const client = await sharedTestPrismaClient() @@ -238,112 +228,7 @@ describe('submitContract', () => { }) // resubmitting should be a store error - expect(resubmitStoreError).toBeInstanceOf(NotFoundError) - }) - - it('invalidates old revisions when new revisions are submitted', async () => { - const client = await sharedTestPrismaClient() - - const stateUser = must( - await client.user.create({ - data: { - id: uuidv4(), - givenName: 'Aang', - familyName: 'Avatar', - email: 'aang@example.com', - role: 'STATE_USER', - stateCode: 'NM', - }, - }) - ) - - // create a draft contract - const draftContractData = mockInsertContractArgs({ - submissionDescription: 'first contract', - }) - const contractA = must( - await insertDraftContract(client, draftContractData) - ) - - // create a draft rate - const rateA = must( - await insertDraftRate(client, { - stateCode: 'MN', - rateCertificationName: 'first rate', - }) - ) - - // submit the first draft contract - const submittedContractA = must( - await submitContract(client, { - contractID: contractA.id, - submittedByUserID: stateUser.id, - submittedReason: 'initial submit', - }) - ) - - // submit the first draft rate - const rateA1 = must( - await submitRate(client, { - rateID: rateA.id, - submittedByUserID: stateUser.id, - submittedReason: 'initial rate submit', - }) - ) - // set up the relation between the submitted contract and the rate - await client.rateRevisionsOnContractRevisionsTable.create({ - data: { - contractRevisionID: submittedContractA.revisions[0].id, - rateRevisionID: rateA1.revisions[0].id, - validAfter: new Date(), - }, - }) - - // create a second draft contract - const contractASecondRevision = must( - await client.contractTable.update({ - where: { - id: contractA.id, - }, - data: { - revisions: { - create: { - submissionType: 'CONTRACT_AND_RATES', - submissionDescription: 'second contract revision', - contractType: 'BASE', - programIDs: draftContractData.programIDs, - populationCovered: 'MEDICAID', - riskBasedContract: false, - }, - }, - }, - include: { - revisions: true, - }, - }) - ) - - // submit the second draft contract - must( - await submitContract(client, { - contractID: contractASecondRevision.id, - submittedByUserID: stateUser.id, - submittedReason: 'second submit', - }) - ) - - /* now that the second contract revision has been submitted, the first contract revision should be invalidated. - Something is invalidated when it gets a validUntil value, which marks the time it stopped being valid */ - const invalidatedRevision = must( - await client.rateRevisionsOnContractRevisionsTable.findFirst({ - where: { - contractRevisionID: submittedContractA.revisions[0].id, - validUntil: { not: null }, - }, - }) - ) - - expect(invalidatedRevision).not.toBeNull() + expect(resubmitStoreError).toBeInstanceOf(Error) }) it('handles concurrent drafts correctly', async () => { @@ -369,7 +254,7 @@ describe('submitContract', () => { // Attempt to submit a rate related to this draft contract const rate1 = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractA.id, { stateCode: 'MN', rateCertificationName: 'onepoint0', }) diff --git a/services/app-api/src/postgres/contractAndRates/submitContract.ts b/services/app-api/src/postgres/contractAndRates/submitContract.ts index 583014136d..196453b557 100644 --- a/services/app-api/src/postgres/contractAndRates/submitContract.ts +++ b/services/app-api/src/postgres/contractAndRates/submitContract.ts @@ -1,4 +1,8 @@ -import type { PrismaClient } from '@prisma/client' +import type { + ContractRevisionTable, + PrismaClient, + RateRevisionTable, +} from '@prisma/client' import type { ContractType } from '../../domain-models/contractAndRates' import { findContractWithHistory } from './findContractWithHistory' import { NotFoundError } from '../postgresErrors' @@ -22,6 +26,21 @@ export async function submitContract( try { return await client.$transaction(async (tx) => { + // New C+R code pre-submit + const currentContract = await findContractWithHistory( + tx, + contractID + ) + if (currentContract instanceof Error) { + return currentContract + } + + if (!currentContract.draftRevision || !currentContract.draftRates) { + return new Error( + 'Attempting to submit a contract that has no draft data' + ) + } + // find the current contract with related rates const currentRev = await client.contractRevisionTable.findFirst({ where: { @@ -41,13 +60,36 @@ export async function submitContract( return new NotFoundError(err) } - // get the related rate revisions and any unsubmitted rates - const relatedRateRevs = currentRev.draftRates.map( - (c) => c.revisions[0] - ) - const unsubmittedRates = relatedRateRevs.filter( - (rev) => rev.submitInfo === null - ) + const unsubmittedChildRevs = [] + const linkedRateRevs = [] + for (const rate of currentContract.draftRates) { + if (rate.parentContractID === contractID) { + if (rate.draftRevision) { + unsubmittedChildRevs.push(rate.draftRevision) + } else { + console.info( + 'Strange, a child rate is not in a draft state. Shouldnt be true while we are unlocking child rates with contracts.' + ) + const latestSubmittedRate = rate.revisions[0] + if (!latestSubmittedRate) { + const msg = `Attempted to submit a contract connected to an unsubmitted child-rate. ContractID: ${contractID}` + return new Error(msg) + } + linkedRateRevs.push(latestSubmittedRate) + } + } else { + // non-child rate + const latestSubmittedRate = rate.revisions[0] + if (!latestSubmittedRate) { + const msg = `Attempted to submit a contract connected to an unsubmitted non-child-rate. ContractID: ${contractID}` + return new Error(msg) + } + linkedRateRevs.push(latestSubmittedRate) + } + } + + // this is all the revs that the newly submitted contract will be connected to. Those to be submitted and those already submitted. + const draftRateRevs = unsubmittedChildRevs.concat(linkedRateRevs) // Create the submitInfo record in the updateInfoTable const submitInfo = await tx.updateInfoTable.create({ @@ -58,6 +100,7 @@ export async function submitContract( }, }) + // OLD SUBMIT STYLE // Update the contract to include the submitInfo ID const updated = await tx.contractRevisionTable.update({ where: { @@ -71,12 +114,12 @@ export async function submitContract( }, rateRevisions: { createMany: { - data: relatedRateRevs.map((rev, idx) => ({ + data: draftRateRevs.map((rev, idx) => ({ rateRevisionID: rev.id, // Since rates come out the other side ordered by validAfter, we need to order things on the way in that way. validAfter: new Date( currentDateTime.getTime() - - relatedRateRevs.length + + draftRateRevs.length + idx + 1 ), @@ -100,7 +143,7 @@ export async function submitContract( await tx.rateRevisionTable.updateMany({ where: { id: { - in: unsubmittedRates.map((rev) => rev.id), + in: unsubmittedChildRevs.map((rev) => rev.id), }, }, data: { @@ -168,9 +211,480 @@ export async function submitContract( }) } + // NEW C+R HISTORY CODE post-submit (updateInfo placed in submitted revs.) + + // Tables-- + // updateInfo: updateInfoID + // draftRates: contractID, rateID, position + // contractRevision: contractID, submissionID + // rateRevision: rateID, submissionID + // relatedContractSubmissions: contractRevisionID, submissionID + // relatedRateSubmissions: rateRevisionID, submissionID + // packageSubmissions: submissionID, contractRevisionID, rateRevisionID + + const submissionRelatedContractRevs: ContractRevisionTable[] = [] + const submissionRelatedRateRevs: RateRevisionTable[] = [] + + const linksToCreate: { + rateRevID: string + contractRevID: string + ratePosition: number + }[] = [] + + // 1. This submitted contract + // all draft rates: mark related, get a connection. + const draftRates = await tx.draftRateJoinTable.findMany({ + where: { + contractID: contractID, + }, + include: { + rate: { + include: { + revisions: { + where: { + submitInfoID: { + not: null, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + }, + }, + }) + const theseDraftRateIDs = draftRates.map((r) => r.rateID) + for (const draftRateJoin of draftRates) { + const draftRate = draftRateJoin.rate + const draftRateRev = draftRate.revisions[0] + if (!draftRateRev) { + const msg = `attempted to submit connected to an UNsubmitted rate. contractID: ${contractID} rateID: ${draftRate.id}` + console.error(msg) + return new Error(msg) + } + + // if not a newly submitted rate, add it to related rates. + if (draftRateRev.submitInfoID !== submitInfo.id) { + submissionRelatedRateRevs.push(draftRateRev) + } + + // add a link. + linksToCreate.push({ + contractRevID: currentRev.id, + rateRevID: draftRateRev.id, + ratePosition: draftRateJoin.ratePosition, + }) + } + + // -- get previous connections, disconnected rate: mark related + const prevRelatedSubmission = await tx.updateInfoTable.findFirst({ + where: { + relatedContracts: { + some: { + contractID: contractID, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + include: { + submissionPackages: { + where: { + contractRevision: { + contractID: contractID, + }, + }, + include: { + rateRevision: true, + }, + }, + }, + }) + + if (prevRelatedSubmission) { + for (const previousConnection of prevRelatedSubmission.submissionPackages) { + if ( + !theseDraftRateIDs.includes( + previousConnection.rateRevision.rateID + ) + ) { + // this previous submission was connected to a now disconnected rate. + submissionRelatedRateRevs.push( + previousConnection.rateRevision + ) + } + } + } + + // 2. these sumbittedrates + // all draft contracts: mark related, get a connection + const submittedRateDraftContracts = + await tx.draftRateJoinTable.findMany({ + where: { + rateID: { + in: unsubmittedChildRevs.map((r) => r.rateID), + }, + }, + include: { + contract: { + include: { + revisions: { + where: { + submitInfoID: { + not: null, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + }, + rate: { + include: { + revisions: { + where: { + submitInfoID: { + not: null, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + }, + }, + }) + + // per-submitted rate, the list of contractIDs it's connected to + const currentSubmittedRateConnections: { + [rateID: string]: string[] + } = {} + + for (const draftContractJoin of submittedRateDraftContracts) { + const submittedRate = draftContractJoin.rate + const submittedRateRev = submittedRate.revisions[0] + + const draftContract = draftContractJoin.contract + const draftContractRev = draftContract.revisions[0] + + if (!submittedRateRev || !draftContractRev) { + const msg = `attempted to submit connected to an UNsubmitted contract or rate. contractID: ${draftContract.id} rateID: ${submittedRate.id}` + console.error(msg) + return new Error(msg) + } + + if (!currentSubmittedRateConnections[submittedRate.id]) { + currentSubmittedRateConnections[submittedRate.id] = [] + } + currentSubmittedRateConnections[submittedRate.id].push( + draftContract.id + ) + + // if not the newly submitted contract, add it to related contracts. + if ( + draftContractRev.submitInfoID !== submitInfo.id && + !submissionRelatedContractRevs.find( + (c) => c.contractID === draftContractRev.contractID + ) + ) { + submissionRelatedContractRevs.push(draftContractRev) + } + + // add a link if it's not already added. + if ( + !linksToCreate.find( + (l) => + l.contractRevID === draftContractRev.id && + l.rateRevID === submittedRateRev.id + ) + ) { + linksToCreate.push({ + contractRevID: draftContractRev.id, + rateRevID: submittedRateRev.id, + ratePosition: draftContractJoin.ratePosition, + }) + } + } + // -- get previous connections, disconnected contracts: mark related, + for (const submittedRateRev of unsubmittedChildRevs) { + const prevRelatedContractSubmissions = + await tx.updateInfoTable.findFirst({ + where: { + relatedRates: { + some: { + rateID: submittedRateRev.rateID, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + include: { + submissionPackages: { + where: { + rateRevision: { + rateID: submittedRateRev.rateID, + }, + }, + include: { + contractRevision: true, + }, + }, + }, + }) + + if (prevRelatedContractSubmissions) { + for (const previousConnection of prevRelatedContractSubmissions.submissionPackages) { + if ( + !currentSubmittedRateConnections[ + submittedRateRev.rateID + ].includes( + previousConnection.contractRevision.contractID + ) + ) { + // this previous submission was connected to a now disconnected rate. + submissionRelatedContractRevs.push( + previousConnection.contractRevision + ) + } + } + } + } + + // all related (not submitted) rates: + for (const relatedRateRev of submissionRelatedRateRevs) { + // -- get previous submision, previous connections + const prevRelatedSubmission = + await tx.updateInfoTable.findFirst({ + where: { + relatedRates: { + some: { + rateID: relatedRateRev.rateID, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + include: { + submissionPackages: { + where: { + rateRevision: { + rateID: relatedRateRev.rateID, + }, + }, + include: { + contractRevision: true, + }, + }, + }, + }) + + if (!prevRelatedSubmission) { + const msg = `Programming Error: Related Rate has no past submission. Should always have a submission. rateID: ${relatedRateRev.rateID}` + console.error(msg) + return new Error(msg) + } + + // -- all connections that aren't this submitted contract, add connection + for (const previousConnection of prevRelatedSubmission.submissionPackages) { + if ( + previousConnection.contractRevision.contractID !== + contractID + ) { + // this previous submission has a link to be forwarded. + + linksToCreate.push({ + contractRevID: + previousConnection.contractRevisionID, + rateRevID: previousConnection.rateRevisionID, + ratePosition: previousConnection.ratePosition, + }) + } + } + } + + // all related contracts: + for (const relatedContract of submissionRelatedContractRevs) { + // -- get previous submission, previous connections + const prevRelatedSubmission = + await tx.updateInfoTable.findFirst({ + where: { + relatedContracts: { + some: { + contractID: relatedContract.contractID, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + include: { + submissionPackages: { + where: { + contractRevision: { + contractID: relatedContract.contractID, + }, + }, + include: { + rateRevision: true, + contractRevision: true, + }, + }, + }, + }) + + if (!prevRelatedSubmission) { + const msg = `Programming Error: Related Contract has no past submission. Should always have a submission. contractID: ${relatedContract.contractID}` + console.error(msg) + return new Error(msg) + } + + // -- all connections that aren't these submitted rates, add connection + const submittedRateIDs = unsubmittedChildRevs.map( + (r) => r.rateID + ) + + for (const previousConnection of prevRelatedSubmission.submissionPackages) { + if ( + !submittedRateIDs.includes( + previousConnection.rateRevision.rateID + ) + ) { + // this previous submission has a link to be forwarded. + + linksToCreate.push({ + contractRevID: + previousConnection.contractRevisionID, + rateRevID: previousConnection.rateRevisionID, + ratePosition: previousConnection.ratePosition, + }) + } + } + } + + // Write the resulting data to the db + // RelatedContractSubmission + const allContractRevisionIDsRelatedToThisSubmission = [ + currentRev.id, + ].concat(submissionRelatedContractRevs.map((r) => r.id)) + // RelatedRateSubmission + const allRateRevisionIDsRelatedToThisSubmission = + unsubmittedChildRevs + .map((r) => r.id) + .concat(submissionRelatedRateRevs.map((r) => r.id)) + // Links + + // all in one! + await tx.updateInfoTable.update({ + where: { id: submitInfo.id }, + data: { + relatedContracts: { + connect: + allContractRevisionIDsRelatedToThisSubmission.map( + (id) => ({ + id: id, + }) + ), + }, + relatedRates: { + connect: allRateRevisionIDsRelatedToThisSubmission.map( + (id) => ({ + id: id, + }) + ), + }, + submissionPackages: { + create: linksToCreate.map((l) => ({ + contractRevisionID: l.contractRevID, + rateRevisionID: l.rateRevID, + ratePosition: l.ratePosition, + })), + }, + }, + }) + + // delete draftRate Connections iff not still connected to other draft revisions. + // we know that all of these submitted contract + rate pairs can be deleted. + // if any of the thisContract -> Rate pairs, those rates have unsubmitted bits, then don't delete that. + // same for rates. + const currentDraftRateLinksForNewSubmissions = + await tx.draftRateJoinTable.findMany({ + where: { + OR: [ + { contractID: contractID }, + { + rateID: { + in: unsubmittedChildRevs.map( + (r) => r.rateID + ), + }, + }, + ], + }, + include: { + contract: { + include: { + revisions: { + orderBy: { + createdAt: 'desc', + }, + }, + }, + }, + rate: { + include: { + revisions: { + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + }, + }, + }) + + // if this connection has no remaining drafts pointing towards it, delete it. + // [contractID, rateID] tuple + const connectionsToRemove: [string, string][] = [] + for (const draftLink of currentDraftRateLinksForNewSubmissions) { + const latestContractRev = draftLink.contract.revisions[0] + const notDraftContract = latestContractRev.submitInfoID !== null + + const latestRateRev = draftLink.rate.revisions[0] + const notDraftRate = latestRateRev.submitInfoID !== null + + if (notDraftContract && notDraftRate) { + connectionsToRemove.push([ + draftLink.contractID, + draftLink.rateID, + ]) + } + } + + for (const connection of connectionsToRemove) { + await tx.draftRateJoinTable.delete({ + where: { + contractID_rateID: { + contractID: connection[0], + rateID: connection[1], + }, + }, + }) + } + return await findContractWithHistory(tx, contractID) }) } catch (err) { + console.error('Submit Prisma Error: ', err) const error = new Error(`Error submitting contract ${err}`) return error } diff --git a/services/app-api/src/postgres/contractAndRates/submitRate.test.ts b/services/app-api/src/postgres/contractAndRates/submitRate.test.ts index 746e93f87a..99ecdc4c8c 100644 --- a/services/app-api/src/postgres/contractAndRates/submitRate.test.ts +++ b/services/app-api/src/postgres/contractAndRates/submitRate.test.ts @@ -1,276 +1,17 @@ import { sharedTestPrismaClient } from '../../testHelpers/storeHelpers' import { v4 as uuidv4 } from 'uuid' import { submitRate } from './submitRate' -import { NotFoundError } from '../postgresErrors' import { - clearDocMetadata, mockInsertContractArgs, mockInsertRateArgs, must, } from '../../testHelpers' -import { insertDraftRate } from './insertRate' import { submitContract } from './submitContract' import { insertDraftContract } from './insertContract' import { updateDraftContractWithRates } from './updateDraftContractWithRates' import { unlockRate } from './unlockRate' -import { findContractWithHistory } from './findContractWithHistory' -import { findStatePrograms } from '../state' -import { unlockContract } from './unlockContract' -import type { RateFormEditableType } from '../../domain-models/contractAndRates' describe('submitRate', () => { - it('creates a standalone rate submission from a draft', async () => { - const client = await sharedTestPrismaClient() - - const stateUser = await client.user.create({ - data: { - id: uuidv4(), - givenName: 'Aang', - familyName: 'Avatar', - email: 'aang@example.com', - role: 'STATE_USER', - stateCode: 'NM', - }, - }) - - // submitting before there's a draft should be an error - const submitError = await submitRate(client, { - rateID: '1111', - submittedByUserID: '1111', - submittedReason: 'failed submit', - }) - expect(submitError).toBeInstanceOf(NotFoundError) - - // create a draft rate - const draftRateData = mockInsertRateArgs({ - rateCertificationName: 'rate-cert-name', - }) - const rateA = must(await insertDraftRate(client, draftRateData)) - // submit the draft contract - const result = must( - await submitRate(client, { - rateID: rateA.id, - submittedByUserID: stateUser.id, - submittedReason: 'Initial submission', - formData: { - ...draftRateData, - rateType: 'AMENDMENT', - }, - }) - ) - - // Expect default submit reason - expect(result.revisions[0].submitInfo?.updatedReason).toBe( - 'Initial submission' - ) - - // 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', - }), - }) - ) - - const resubmitStoreError = await submitRate(client, { - rateID: rateA.id, - submittedByUserID: stateUser.id, - submittedReason: 'Resubmit', - }) - - // resubmitting should be a store error - expect(resubmitStoreError).toBeInstanceOf(NotFoundError) - }) - - it('invalidates old revisions when new revisions are submitted', async () => { - const client = await sharedTestPrismaClient() - - const stateUser = must( - await client.user.create({ - data: { - id: uuidv4(), - givenName: 'Aang', - familyName: 'Avatar', - email: 'aang@example.com', - role: 'STATE_USER', - stateCode: 'NM', - }, - }) - ) - - // create a draft rate - const draftRateData = mockInsertRateArgs({ - rateCertificationName: 'first rate ', - }) - const rateA = must(await insertDraftRate(client, draftRateData)) - - // create a draft contract - const contractA = must( - await insertDraftContract( - client, - mockInsertContractArgs({ - submissionDescription: 'first contract', - }) - ) - ) - - // submit the first draft rate with no associated contracts - const submittedRateA = must( - await submitRate(client, { - rateID: rateA.id, - submittedByUserID: stateUser.id, - submittedReason: 'initial submit', - }) - ) - - // submit the contract - const contractA1 = must( - await submitContract(client, { - contractID: contractA.id, - submittedByUserID: stateUser.id, - submittedReason: 'initial rate submit', - }) - ) - // set up the relation between the submitted contract and the rate - await client.rateRevisionsOnContractRevisionsTable.create({ - data: { - rateRevisionID: submittedRateA.revisions[0].id, - contractRevisionID: contractA1.revisions[0].id, - validAfter: new Date(), - }, - }) - - // create a second draft rate - const rateASecondRevision = must( - await client.rateTable.update({ - where: { - id: rateA.id, - }, - data: { - revisions: { - create: { - rateCertificationName: 'second contract revision', - }, - }, - }, - include: { - revisions: true, - }, - }) - ) - - // submit the second draft rate - must( - await submitRate(client, { - rateID: rateASecondRevision.id, - submittedByUserID: stateUser.id, - submittedReason: 'second submit', - }) - ) - - /* now that the second rate revision has been submitted, the first rate revision should be invalidated. - Something is invalidated when it gets a validUntil value, which marks the time it stopped being valid */ - const invalidatedRevision = must( - await client.rateRevisionsOnContractRevisionsTable.findFirst({ - where: { - rateRevisionID: submittedRateA.revisions[0].id, - validUntil: { not: null }, - }, - }) - ) - - expect(invalidatedRevision).not.toBeNull() - }) - - it('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 = mockInsertRateArgs({ - 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: RateFormEditableType = { - ...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, - submittedReason: 'submit and update rate', - formData: updateRateData, - }) - ) - - expect({ - ...submittedRate.revisions[0].formData, - rateDocuments: clearDocMetadata( - submittedRate.revisions[0].formData.rateDocuments - ), - supportingDocuments: clearDocMetadata( - submittedRate.revisions[0].formData.supportingDocuments - ), - }).toEqual(expect.objectContaining(updateRateData)) - }) it('submits rate independent of contract status', async () => { const client = await sharedTestPrismaClient() @@ -330,43 +71,7 @@ describe('submitRate', () => { } const rateID = - updatedDraftContract.draftRevision.rateRevisions[0].rate.id - - // submit rate - const submittedRate = must( - await submitRate(client, { - rateID, - submittedByUserID: stateUser.id, - submittedReason: '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', - }) - ) + updatedDraftContract.draftRevision.rateRevisions[0].rateID const submittedContract = must( await submitContract(client, { @@ -381,8 +86,8 @@ describe('submitRate', () => { submittedContract.revisions[0].rateRevisions[0].formData ).toEqual( expect.objectContaining({ - rateCertificationName: 'rate revision 1.1', - rateType: 'AMENDMENT', + rateCertificationName: 'rate revision 1.0', + rateType: 'NEW', }) ) @@ -395,14 +100,6 @@ describe('submitRate', () => { }) ) - must( - await unlockContract(client, { - contractID: draftContract.id, - unlockReason: 'dosmsdfs', - unlockedByUserID: cmsUser.id, - }) - ) - must( await submitRate(client, { rateID, diff --git a/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts b/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts index cdc0ae8559..07a59fe1e1 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockContract.test.ts @@ -10,6 +10,7 @@ import { updateDraftRate } from './updateDraftRate' import { submitContract } from './submitContract' import { findContractWithHistory } from './findContractWithHistory' import { must, mockInsertContractArgs } from '../../testHelpers' +import { updateDraftContractRates } from './updateDraftContractRates' describe('unlockContract', () => { it('Unlocks a rate without breaking connected draft contract', async () => { @@ -45,51 +46,60 @@ describe('unlockContract', () => { await insertDraftContract(client, draftContractData) ) const rate = must( - await insertDraftRate(client, { + await insertDraftRate(client, contract.id, { stateCode: 'MN', rateCertificationName: 'Rate 1.0', }) ) - // Submit Rate A - const submittedRate = must( - await submitRate(client, { - rateID: rate.id, + // Submit Contract With Rate A + const submittedContractWithRateA = must( + await submitContract(client, { + contractID: contract.id, submittedByUserID: stateUser.id, submittedReason: 'Rate A 1.0 submit', }) ) + const submittedRateAID = + submittedContractWithRateA.packageSubmissions[0].rateRevisions[0] + .rateID + + const contract2 = must( + await insertDraftContract(client, draftContractData) + ) // Connect draft contract to submitted rate must( - await updateDraftContractWithRates(client, { - contractID: contract.id, - formData: { - submissionType: 'CONTRACT_AND_RATES', - submissionDescription: 'Connecting rate', - contractType: 'BASE', - programIDs: ['PMAP'], - populationCovered: 'MEDICAID', - riskBasedContract: false, + await updateDraftContractRates(client, { + contractID: contract2.id, + rateUpdates: { + create: [], + update: [], + link: [ + { + rateID: rate.id, + ratePosition: 1, + }, + ], + unlink: [], + delete: [], }, - rateFormDatas: [submittedRate.revisions[0].formData], }) ) const fullDraftContract = must( - await findContractWithHistory(client, contract.id) + await findContractWithHistory(client, contract2.id) ) - const draftContract = fullDraftContract.draftRevision + const draftContractRev = fullDraftContract.draftRevision + const draftRates = fullDraftContract.draftRates - if (draftContract === undefined) { + if (draftContractRev === undefined || draftRates === undefined) { throw Error('Unexpect error: draft contract missing draft revision') } // Rate revision should be connected to contract - expect(draftContract.rateRevisions[0].id).toEqual( - submittedRate.revisions[0].id - ) + expect(draftRates[0].id).toEqual(submittedRateAID) // Unlock the rate must( @@ -116,19 +126,21 @@ describe('unlockContract', () => { ) const fullDraftContractTwo = must( - await findContractWithHistory(client, contract.id) + await findContractWithHistory(client, contract2.id) ) - const draftContractTwo = fullDraftContractTwo.draftRevision + const draftContractRevTwo = fullDraftContractTwo.draftRevision + const draftContractTwoDraftRates = fullDraftContractTwo.draftRates - if (draftContractTwo === undefined) { + if ( + draftContractRevTwo === undefined || + draftContractTwoDraftRates === undefined + ) { throw Error('Unexpect error: draft contract missing draft revision') } // Contract should now have the latest rate revision - expect(draftContractTwo.rateRevisions[0].id).toEqual( - resubmittedRate.revisions[0].id - ) + expect(draftContractTwoDraftRates[0].id).toEqual(resubmittedRate.id) }) // This is unlocking a rate without unlocking the contract that this rate belongs to. Then it updates the rate and resubmits. @@ -136,6 +148,7 @@ describe('unlockContract', () => { // This test does not simulate how creating/updating a rate currently works in our app and the contract revision history // will not match. // Skipping this for now, revisit during rate only feature work. + // eslint-disable-next-line jest/no-disabled-tests it.skip('Unlocks a rate without breaking connected submitted contract', async () => { const client = await sharedTestPrismaClient() @@ -169,7 +182,7 @@ describe('unlockContract', () => { await insertDraftContract(client, draftContractData) ) const rate = must( - await insertDraftRate(client, { + await insertDraftRate(client, contract.id, { stateCode: 'MN', rateCertificationName: 'Rate 1.0', }) @@ -285,7 +298,7 @@ describe('unlockContract', () => { await insertDraftContract(client, draftContractData) ) const rate = must( - await insertDraftRate(client, { + await insertDraftRate(client, contract.id, { stateCode: 'MN', rateCertificationName: 'rate 1.0', }) @@ -297,31 +310,6 @@ describe('unlockContract', () => { ) } - // Connect draft contract to draft rate - must( - await updateDraftContractWithRates(client, { - contractID: contract.id, - formData: { - submissionType: 'CONTRACT_AND_RATES', - submissionDescription: 'contract 1.0', - contractType: 'BASE', - programIDs: ['PMAP'], - populationCovered: 'MEDICAID', - riskBasedContract: false, - }, - rateFormDatas: [rate.draftRevision?.formData], - }) - ) - - // Submit rate - const submittedRate = must( - await submitRate(client, { - rateID: rate.id, - submittedByUserID: stateUser.id, - submittedReason: 'Submit rate 1.0', - }) - ) - // Submit contract const submittedContract = must( await submitContract(client, { @@ -330,10 +318,10 @@ describe('unlockContract', () => { submittedReason: 'Submit contract 1.0', }) ) - const latestContractRev = submittedContract.revisions[0] + const lastestSubmission = submittedContract.packageSubmissions[0] - expect(latestContractRev.rateRevisions[0].id).toEqual( - submittedRate.revisions[0].id + expect(lastestSubmission.rateRevisions[0].id).toEqual( + rate.draftRevision.id ) // Unlock and resubmit contract @@ -344,20 +332,6 @@ describe('unlockContract', () => { unlockReason: 'First unlock', }) ) - must( - await updateDraftContractWithRates(client, { - contractID: contract.id, - formData: { - submissionType: 'CONTRACT_AND_RATES', - submissionDescription: 'contract 2.0', - contractType: 'BASE', - programIDs: ['PMAP'], - populationCovered: 'MEDICAID', - riskBasedContract: false, - }, - rateFormDatas: [rate.draftRevision?.formData], - }) - ) const resubmittedContract = must( await submitContract(client, { @@ -366,11 +340,11 @@ describe('unlockContract', () => { submittedReason: 'Submit contract 2.0', }) ) - const latestResubmittedRev = resubmittedContract.revisions[0] + const latestResubmission = resubmittedContract.packageSubmissions[0] // Expect rate revision to still be connected - expect(latestResubmittedRev.rateRevisions[0].id).toEqual( - submittedRate.revisions[0].id + expect(latestResubmission.rateRevisions[0].rateID).toEqual( + rate.draftRevision.rateID ) }) it('errors when unlocking a draft contract or rate', async () => { @@ -395,7 +369,7 @@ describe('unlockContract', () => { await insertDraftContract(client, draftContractData) ) const rateA = must( - await insertDraftRate(client, { + await insertDraftRate(client, contractA.id, { stateCode: 'MN', rateCertificationName: 'rate A 1.1', }) diff --git a/services/app-api/src/postgres/contractAndRates/unlockContract.ts b/services/app-api/src/postgres/contractAndRates/unlockContract.ts index 2a4e4b2a77..1c123f1c03 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockContract.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockContract.ts @@ -2,6 +2,7 @@ import type { PrismaClient } from '@prisma/client' import type { ContractType } from '../../domain-models/contractAndRates' import { findContractWithHistory } from './findContractWithHistory' import { NotFoundError } from '../postgresErrors' +import { unlockRateInDB } from './unlockRate' type UnlockContractArgsType = { contractID: string @@ -10,20 +11,61 @@ type UnlockContractArgsType = { } // Unlock the given contract +// * unlock child rates // * copy form data // * set relationships based on last submission async function unlockContract( client: PrismaClient, args: UnlockContractArgsType ): Promise { - const groupTime = new Date() - const { contractID, unlockedByUserID, unlockReason } = args try { return await client.$transaction(async (tx) => { - // Given all the Rates associated with this draft, find the most recent submitted - // rateRevision to attach to this contract on submit. + // This finds the child rates for this submission. + // A child rate is a rate that shares a submit info with this contract. + // technically only a rate that is _initially_ submitted with a contract + // is a child rate, but we should never allow re-submission so this simpler + // query that doesn't try to filter to initial revisions works. + const childRates = await tx.rateTable.findMany({ + where: { + revisions: { + some: { + submitInfo: { + submittedContracts: { + some: { + contractID: contractID, + }, + }, + }, + }, + }, + }, + }) + + const currentDateTime = new Date() + // create the unlock info to be shared across all submissions. + const unlockInfo = await tx.updateInfoTable.create({ + data: { + updatedAt: currentDateTime, + updatedByID: unlockedByUserID, + updatedReason: unlockReason, + }, + }) + + // unlock child rates with that unlock info + for (const childRate of childRates) { + const unlockRate = await unlockRateInDB( + tx, + childRate.id, + unlockInfo.id + ) + if (unlockRate instanceof Error) { + throw unlockRate + } + } + + // get the last submitted rev in order to unlock it const currentRev = await tx.contractRevisionTable.findFirst({ where: { contractID: contractID, @@ -48,6 +90,23 @@ async function unlockContract( }, }, + relatedSubmisions: { + orderBy: { + updatedAt: 'desc', + }, + take: 1, + include: { + submissionPackages: { + include: { + rateRevision: true, + }, + orderBy: { + ratePosition: 'asc', + }, + }, + }, + }, + rateRevisions: { where: { validUntil: null, @@ -77,6 +136,15 @@ async function unlockContract( (c) => c.rateRevision.rateID ) + // find the rates in the last submission package: + const lastSubmission = currentRev.relatedSubmisions[0] + const thisContractsRatePackages = + lastSubmission.submissionPackages.filter( + (p) => p.contractRevisionID === currentRev.id + ) + const relatedRateIDs = thisContractsRatePackages.map( + (p) => p.rateRevision.rateID + ) await tx.contractRevisionTable.create({ data: { contract: { @@ -85,11 +153,7 @@ async function unlockContract( }, }, unlockInfo: { - create: { - updatedAt: groupTime, - updatedByID: unlockedByUserID, - updatedReason: unlockReason, - }, + connect: { id: unlockInfo.id }, }, draftRates: { connect: previouslySubmittedRateIDs.map((cID) => ({ @@ -180,6 +244,23 @@ async function unlockContract( }, }) + // connect draftRates + let position = 1 + const joins = relatedRateIDs.map((id) => { + const thisPosition = position + position++ + return { + contractID: currentRev.contractID, + rateID: id, + ratePosition: thisPosition, + } + }) + + await tx.draftRateJoinTable.createMany({ + data: joins, + skipDuplicates: true, + }) + return findContractWithHistory(tx, contractID) }) } catch (err) { diff --git a/services/app-api/src/postgres/contractAndRates/unlockRate.ts b/services/app-api/src/postgres/contractAndRates/unlockRate.ts index 9ed647b5d5..703c5320c5 100644 --- a/services/app-api/src/postgres/contractAndRates/unlockRate.ts +++ b/services/app-api/src/postgres/contractAndRates/unlockRate.ts @@ -1,5 +1,6 @@ import type { PrismaClient } from '@prisma/client' import type { RateType } from '../../domain-models/contractAndRates' +import type { PrismaTransactionType } from '../prismaTypes' import { findRateWithHistory } from './findRateWithHistory' type UnlockRateArgsType = { @@ -9,6 +10,195 @@ type UnlockRateArgsType = { unlockReason: string } +async function unlockRateInDB( + tx: PrismaTransactionType, + rateID: string, + unlockInfoID: string +): Promise { + // find the current rate revision in order to create a new unlocked revision + const currentRev = await tx.rateRevisionTable.findFirst({ + where: { + rateID, + }, + include: { + rateDocuments: { + orderBy: { + position: 'asc', + }, + }, + supportingDocuments: { + orderBy: { + position: 'asc', + }, + }, + certifyingActuaryContacts: { + orderBy: { + position: 'asc', + }, + }, + addtlActuaryContacts: { + orderBy: { + position: 'asc', + }, + }, + contractsWithSharedRateRevision: true, + + contractRevisions: { + where: { + validUntil: null, + }, + include: { + contractRevision: true, + }, + }, + relatedSubmissions: { + orderBy: { + updatedAt: 'desc', + }, + take: 1, + include: { + submissionPackages: { + include: { + contractRevision: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + 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' + ) + } + + if (!currentRev.submitInfoID) { + console.error( + 'Programming Error: cannot unlock a already unlocked rate' + ) + return new Error( + 'Programming Error: cannot unlock a already unlocked rate' + ) + } + + const previouslySubmittedContractIDs = currentRev.contractRevisions.map( + (c) => c.contractRevision.contractID + ) + + const prevContractsWithSharedRateRevisionIDs = + currentRev.contractsWithSharedRateRevision.map( + (contract) => contract.id + ) + + await tx.rateRevisionTable.create({ + data: { + rate: { + connect: { + id: currentRev.rateID, + }, + }, + unlockInfo: { + connect: { id: unlockInfoID }, + }, + draftContracts: { + connect: previouslySubmittedContractIDs.map((cID) => ({ + id: cID, + })), + }, + + rateType: currentRev.rateType, + rateCapitationType: currentRev.rateCapitationType, + rateDateStart: currentRev.rateDateStart, + rateDateEnd: currentRev.rateDateEnd, + rateDateCertified: currentRev.rateDateCertified, + amendmentEffectiveDateEnd: currentRev.amendmentEffectiveDateEnd, + amendmentEffectiveDateStart: currentRev.amendmentEffectiveDateStart, + rateProgramIDs: currentRev.rateProgramIDs, + rateCertificationName: currentRev.rateCertificationName, + actuaryCommunicationPreference: + currentRev.actuaryCommunicationPreference, + + rateDocuments: { + create: currentRev.rateDocuments.map((d) => ({ + position: d.position, + name: d.name, + s3URL: d.s3URL, + sha256: d.sha256, + })), + }, + supportingDocuments: { + create: currentRev.supportingDocuments.map((d) => ({ + position: d.position, + name: d.name, + s3URL: d.s3URL, + sha256: d.sha256, + })), + }, + certifyingActuaryContacts: { + create: currentRev.certifyingActuaryContacts.map((c) => ({ + position: c.position, + name: c.name, + email: c.email, + titleRole: c.titleRole, + actuarialFirm: c.actuarialFirm, + actuarialFirmOther: c.actuarialFirmOther, + })), + }, + addtlActuaryContacts: { + create: currentRev.addtlActuaryContacts.map((c) => ({ + position: c.position, + name: c.name, + email: c.email, + titleRole: c.titleRole, + actuarialFirm: c.actuarialFirm, + actuarialFirmOther: c.actuarialFirmOther, + })), + }, + contractsWithSharedRateRevision: { + connect: prevContractsWithSharedRateRevisionIDs.map( + (contractID) => ({ + id: contractID, + }) + ), + }, + }, + include: { + contractRevisions: { + include: { + contractRevision: true, + }, + }, + }, + }) + + // add DraftContract connections to the Rate + const lastSubmission = currentRev.relatedSubmissions[0] + const submissionConnections = lastSubmission.submissionPackages.filter( + (p) => p.rateRevisionID === currentRev.id + ) + const newDraftConnections = [] + for (const submissionConnection of submissionConnections) { + newDraftConnections.push({ + contractID: submissionConnection.contractRevision.contractID, + rateID: currentRev.rateID, + ratePosition: submissionConnection.ratePosition, + }) + } + + await tx.draftRateJoinTable.createMany({ + data: newDraftConnections, + skipDuplicates: true, + }) + + return currentRev.id +} + // Unlock the given rate // * copy form data // * set relationships based on last submission @@ -16,8 +206,8 @@ async function unlockRate( client: PrismaClient, args: UnlockRateArgsType ): Promise { - const groupTime = new Date() - const { rateID, rateRevisionID, unlockedByUserID, unlockReason } = args + const { rateRevisionID, unlockedByUserID, unlockReason } = args + let rateID = args.rateID // 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 @@ -29,172 +219,36 @@ async function unlockRate( try { return await client.$transaction(async (tx) => { - const findWhere = rateRevisionID - ? { - id: rateRevisionID, - } - : { - rateID, - } - - // Given all the Rates associated with this draft, find the most recent submitted - // rateRevision to attach to this contract on submit. - const currentRev = await tx.rateRevisionTable.findFirst({ - where: findWhere, - include: { - rateDocuments: { - orderBy: { - position: 'asc', - }, - }, - supportingDocuments: { - orderBy: { - position: 'asc', - }, - }, - certifyingActuaryContacts: { - orderBy: { - position: 'asc', - }, - }, - addtlActuaryContacts: { - orderBy: { - position: 'asc', - }, - }, - contractsWithSharedRateRevision: true, - - contractRevisions: { - where: { - validUntil: null, - }, - include: { - contractRevision: true, - }, - }, - }, - orderBy: { - createdAt: 'desc', - }, - }) - 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' - ) + if (rateRevisionID) { + const rate = await tx.rateRevisionTable.findUniqueOrThrow({ + where: { id: rateRevisionID }, + }) + rateID = rate.id } - if (!currentRev.submitInfoID) { - console.error( - 'Programming Error: cannot unlock a already unlocked rate' - ) - return new Error( - 'Programming Error: cannot unlock a already unlocked rate' + if (!rateID) { + throw new Error( + 'Programming Error: we must have a rateID at this point.' ) } - const previouslySubmittedContractIDs = - currentRev.contractRevisions.map( - (c) => c.contractRevision.contractID - ) - - const prevContractsWithSharedRateRevisionIDs = - currentRev.contractsWithSharedRateRevision.map( - (contract) => contract.id - ) - - await tx.rateRevisionTable.create({ + const currentDateTime = new Date() + // create the unlock info to be shared across all submissions. + const unlockInfo = await tx.updateInfoTable.create({ data: { - rate: { - connect: { - id: currentRev.rateID, - }, - }, - unlockInfo: { - create: { - updatedAt: groupTime, - updatedByID: unlockedByUserID, - updatedReason: unlockReason, - }, - }, - draftContracts: { - connect: previouslySubmittedContractIDs.map((cID) => ({ - id: cID, - })), - }, - - rateType: currentRev.rateType, - rateCapitationType: currentRev.rateCapitationType, - rateDateStart: currentRev.rateDateStart, - rateDateEnd: currentRev.rateDateEnd, - rateDateCertified: currentRev.rateDateCertified, - amendmentEffectiveDateEnd: - currentRev.amendmentEffectiveDateEnd, - amendmentEffectiveDateStart: - currentRev.amendmentEffectiveDateStart, - rateProgramIDs: currentRev.rateProgramIDs, - rateCertificationName: currentRev.rateCertificationName, - actuaryCommunicationPreference: - currentRev.actuaryCommunicationPreference, - - rateDocuments: { - create: currentRev.rateDocuments.map((d) => ({ - position: d.position, - name: d.name, - s3URL: d.s3URL, - sha256: d.sha256, - })), - }, - supportingDocuments: { - create: currentRev.supportingDocuments.map((d) => ({ - position: d.position, - name: d.name, - s3URL: d.s3URL, - sha256: d.sha256, - })), - }, - certifyingActuaryContacts: { - create: currentRev.certifyingActuaryContacts.map( - (c) => ({ - position: c.position, - name: c.name, - email: c.email, - titleRole: c.titleRole, - actuarialFirm: c.actuarialFirm, - actuarialFirmOther: c.actuarialFirmOther, - }) - ), - }, - addtlActuaryContacts: { - create: currentRev.addtlActuaryContacts.map((c) => ({ - position: c.position, - name: c.name, - email: c.email, - titleRole: c.titleRole, - actuarialFirm: c.actuarialFirm, - actuarialFirmOther: c.actuarialFirmOther, - })), - }, - contractsWithSharedRateRevision: { - connect: prevContractsWithSharedRateRevisionIDs.map( - (contractID) => ({ - id: contractID, - }) - ), - }, - }, - include: { - contractRevisions: { - include: { - contractRevision: true, - }, - }, + updatedAt: currentDateTime, + updatedByID: unlockedByUserID, + updatedReason: unlockReason, }, }) - return findRateWithHistory(tx, currentRev.rateID) + const submittedID = await unlockRateInDB(tx, rateID, unlockInfo.id) + + if (submittedID instanceof Error) { + throw submittedID + } + + return findRateWithHistory(tx, rateID) }) } catch (err) { console.error('SUBMIT PRISMA CONTRACT ERR', err) @@ -202,5 +256,5 @@ async function unlockRate( } } -export { unlockRate } +export { unlockRate, unlockRateInDB } export type { UnlockRateArgsType } diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts index de8111c871..abaab1d861 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractRates.ts @@ -4,6 +4,7 @@ import type { RateFormEditableType, } from '../../domain-models/contractAndRates' import { NotFoundError } from '../postgresErrors' +import type { PrismaTransactionType } from '../prismaTypes' import { findContractWithHistory } from './findContractWithHistory' import { prismaRateCreateFormDataFromDomain, @@ -13,13 +14,16 @@ import { interface UpdatedRatesType { create: { formData: RateFormEditableType + ratePosition: number }[] update: { rateID: string formData: RateFormEditableType + ratePosition: number }[] link: { rateID: string + ratePosition: number }[] unlink: { rateID: string @@ -34,124 +38,227 @@ interface UpdateDraftContractRatesArgsType { rateUpdates: UpdatedRatesType } +async function updateDraftContractRatesInTransaction( + tx: PrismaTransactionType, + args: UpdateDraftContractRatesArgsType +): Promise { + // for now, get the latest contract revision, eventually we'll have rate revisions directly on this + const contract = await tx.contractTable.findUnique({ + where: { + id: args.contractID, + }, + include: { + revisions: { + take: 1, + orderBy: { + createdAt: 'desc', + }, + }, + }, + }) + + if (!contract) { + return new NotFoundError( + 'contract not found with ID: ' + args.contractID + ) + } + + const draftRevision = contract.revisions[0] + if (!draftRevision) { + return new Error( + 'PROGRAMMER ERROR: This draft contract has no draft revision' + ) + } + + // figure out the rate number range for created rates. + const state = await tx.state.findUnique({ + where: { stateCode: contract.stateCode }, + }) + + if (!state) { + return new Error( + 'PROGRAMER ERROR: No state found with code: ' + contract.stateCode + ) + } + + let nextRateNumber = state.latestStateRateCertNumber + 1 + + // create new rates with new revisions + const createdRateJoins: { rateID: string; ratePosition: number }[] = [] + for (const createRateArg of args.rateUpdates.create) { + const rateFormData = createRateArg.formData + const thisRateNumber = nextRateNumber + nextRateNumber++ + + const rateToCreate = { + stateCode: contract.stateCode, + stateNumber: thisRateNumber, + revisions: { + create: prismaRateCreateFormDataFromDomain(rateFormData), + }, + } + + const createdRate = await tx.rateTable.create({ + data: rateToCreate, + include: { + revisions: true, + }, + }) + + createdRateJoins.push({ + rateID: createdRate.id, + ratePosition: createRateArg.ratePosition, + }) + } + + // to delete draft rates, we need to delete their revisions first + await tx.rateRevisionTable.deleteMany({ + where: { + rateID: { + in: args.rateUpdates.delete.map((ru) => ru.rateID), + }, + }, + }) + await tx.rateTable.deleteMany({ + where: { + id: { + in: args.rateUpdates.delete.map((ru) => ru.rateID), + }, + }, + }) + + const oldLinksToCreate = [ + ...createdRateJoins.map((lr) => lr.rateID), + ...args.rateUpdates.link.map((ru) => ru.rateID), + ] + + // create new rates and link and unlink others + await tx.contractRevisionTable.update({ + where: { id: draftRevision.id }, + data: { + draftRates: { + connect: oldLinksToCreate.map((rID) => ({ + id: rID, + })), + disconnect: args.rateUpdates.unlink.map((ru) => ({ + id: ru.rateID, + })), + }, + }, + include: { + draftRates: true, + }, + }) + + // new rate + contract Linking tables + + // for each of the links, we have to get the order right + // all the newly valid links are from create/update/link + const links: { rateID: string; ratePosition: number }[] = [ + ...createdRateJoins.map((rj) => ({ + rateID: rj.rateID, + ratePosition: rj.ratePosition, + })), + ...args.rateUpdates.update.map((ru) => ({ + rateID: ru.rateID, + ratePosition: ru.ratePosition, + })), + ...args.rateUpdates.link.map((ru) => ({ + rateID: ru.rateID, + ratePosition: ru.ratePosition, + })), + ] + + // Check our work, these should be an incrementing list of ratePositions. + const ratePositions = links.map((l) => l.ratePosition).sort() + let lastPosition = 0 + for (const ratePosition of ratePositions) { + if (ratePosition !== lastPosition + 1) { + console.error( + 'Updated Rate ratePositions Are Not Ordered', + ratePositions + ) + return new Error( + 'updateDraftContractRates called with discontinuous order ratePositions' + ) + } + lastPosition++ + } + + await tx.contractTable.update({ + where: { id: args.contractID }, + data: { + draftRates: { + deleteMany: {}, + create: links, + }, + }, + }) + + // end new R+C Contract Linking Tables + + // update existing rates + for (const ru of args.rateUpdates.update) { + const draftRev = await tx.rateRevisionTable.findFirst({ + where: { + rateID: ru.rateID, + submitInfoID: null, + }, + }) + + if (!draftRev) { + return new Error( + 'attempting to update a rate that is not editable: ' + ru.rateID + ) + } + + await tx.rateRevisionTable.update({ + where: { id: draftRev.id }, + data: prismaUpdateRateFormDataFromDomain(ru.formData), + }) + } + + // unlink old data from disconnected rates + for (const ru of args.rateUpdates.unlink) { + const draftRev = await tx.rateRevisionTable.findFirst({ + where: { + rateID: ru.rateID, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + if (!draftRev) { + return new Error( + 'attempting to unlink a rate with no revision: ' + ru.rateID + ) + } + + await tx.rateRevisionTable.update({ + where: { id: draftRev.id }, + data: { + draftContracts: { + disconnect: { + id: args.contractID, + }, + }, + }, + }) + } + + return findContractWithHistory(tx, args.contractID) +} + async function updateDraftContractRates( client: PrismaClient, args: UpdateDraftContractRatesArgsType ): Promise { try { return await client.$transaction(async (tx) => { - // for now, get the latest contract revision, eventually we'll have rate revisions directly on this - const contract = await tx.contractTable.findUnique({ - where: { - id: args.contractID, - }, - include: { - revisions: { - take: 1, - orderBy: { - createdAt: 'desc', - }, - }, - }, - }) - - if (!contract) { - return new NotFoundError( - 'contract not found with ID: ' + args.contractID - ) - } - - const draftRevision = contract.revisions[0] - if (!draftRevision) { - return new Error( - 'PROGRAMMER ERROR: This draft contract has no draft revision' - ) - } - - // figure out the rate number range for created rates. - const state = await tx.state.findUnique({ - where: { stateCode: contract.stateCode }, - }) - - if (!state) { - return new Error( - 'PROGRAMER ERROR: No state found with code: ' + - contract.stateCode - ) - } - - let nextRateNumber = state.latestStateRateCertNumber + 1 - - // create new rates with new revisions - const ratesToCreate = args.rateUpdates.create.map((ru) => { - const rateFormData = ru.formData - const thisRateNumber = nextRateNumber - nextRateNumber++ - return { - stateCode: contract.stateCode, - stateNumber: thisRateNumber, - revisions: { - create: prismaRateCreateFormDataFromDomain( - rateFormData - ), - }, - } - }) - - // to delete draft rates, we need to delete their revisions first - await tx.rateRevisionTable.deleteMany({ - where: { - rateID: { - in: args.rateUpdates.delete.map((ru) => ru.rateID), - }, - }, - }) - - // create new rates and link and unlink others - await tx.contractRevisionTable.update({ - where: { id: draftRevision.id }, - data: { - draftRates: { - create: ratesToCreate, - connect: args.rateUpdates.link.map((ru) => ({ - id: ru.rateID, - })), - disconnect: args.rateUpdates.unlink.map((ru) => ({ - id: ru.rateID, - })), - delete: args.rateUpdates.delete.map((ru) => ({ - id: ru.rateID, - })), - }, - }, - include: { - draftRates: true, - }, - }) - - // update existing rates - for (const ru of args.rateUpdates.update) { - const draftRev = await tx.rateRevisionTable.findFirst({ - where: { - rateID: ru.rateID, - submitInfoID: null, - }, - }) - - if (!draftRev) { - return new Error( - 'attempting to update a rate that is not editable: ' + - ru.rateID - ) - } - - await tx.rateRevisionTable.update({ - where: { id: draftRev.id }, - data: prismaUpdateRateFormDataFromDomain(ru.formData), - }) - } - - return findContractWithHistory(tx, args.contractID) + const result = await updateDraftContractRatesInTransaction(tx, args) + + return result }) } catch (err) { console.error('PRISMA ERR', err) @@ -161,4 +268,4 @@ async function updateDraftContractRates( export type { UpdateDraftContractRatesArgsType } -export { updateDraftContractRates } +export { updateDraftContractRates, updateDraftContractRatesInTransaction } diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.test.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.test.ts index 074146312f..7436513bfe 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.test.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.test.ts @@ -17,6 +17,8 @@ import { mockInsertRateArgs } from '../../testHelpers/rateDataMocks' import { v4 as uuidv4 } from 'uuid' import { insertDraftRate } from './insertRate' import { submitRate } from './submitRate' +import { submitContract } from './submitContract' +import { unlockContract } from './unlockContract' describe('updateDraftContractWithRates postgres', () => { afterEach(() => { @@ -758,6 +760,7 @@ describe('updateDraftContractWithRates postgres', () => { const draftRate = must( await insertDraftRate( client, + draftContract.id, mockInsertRateArgs({ rateType: 'NEW', stateCode: 'MN', @@ -861,19 +864,41 @@ describe('updateDraftContractWithRates postgres', () => { // expect 1 rate expect(newlyCreatedRates).toHaveLength(1) - // submit rate - const submittedExistingRate = must( + // submit contract + must( + await submitContract(client, { + contractID: draftContract.id, + submittedByUserID: stateUser.id, + submittedReason: 'Contract submit', + }) + ) + + must( + await unlockContract(client, { + contractID: draftContract.id, + unlockedByUserID: stateUser.id, + unlockReason: 'Contract unlock', + }) + ) + + // resubmit this rate separate from the contract. Technically probably not allowed. + must( await submitRate(client, { - rateID: newlyCreatedRates[0].formData.rateID, + rateID: newlyCreatedRates[0].rateID, submittedByUserID: stateUser.id, submittedReason: 'Rate submit', }) ) // Create and submit a new rate that is type 'AMENDMENT' + const secondContract = must( + await insertDraftContract(client, draftContractFormData) + ) + const newDraftRate = must( await insertDraftRate( client, + secondContract.id, mockInsertRateArgs({ id: uuidv4(), rateType: 'AMENDMENT', @@ -881,61 +906,39 @@ describe('updateDraftContractWithRates postgres', () => { ) ) - const newSubmittedRate = must( - await submitRate(client, { - rateID: newDraftRate.id, + if (!newDraftRate.draftRevision) { + throw new Error('NO draft') + } + + must( + await submitContract(client, { + contractID: secondContract.id, submittedByUserID: stateUser.id, - submittedReason: 'Rate 2 submit', + submittedReason: 'Contract submit with amendment rate', }) ) - if ( - !submittedExistingRate.revisions[0] || - !newSubmittedRate.revisions[0] - ) { - throw new Error( - 'Unexpected error. Submitted rates did not contain revisions' - ) - } - // Update contract with submitted rate and try to update the submitted rate revision - const attemptToUpdateSubmittedRate = must( - await updateDraftContractWithRates(client, { + const attemptToUpdateSubmittedRate = await updateDraftContractWithRates( + client, + { contractID: updatedContractWithNewRates.id, formData: {}, rateFormDatas: [ // attempt to update the revision data of a submitted rate 1. { - ...submittedExistingRate.revisions[0].formData, + ...newlyCreatedRates[0].formData, rateType: 'AMENDMENT', }, // Connect submitted rate 2 and try to update the rate data { - ...newSubmittedRate.revisions[0].formData, + ...newDraftRate.draftRevision.formData, rateType: 'NEW', }, ], - }) + } ) - if (!attemptToUpdateSubmittedRate.draftRevision) { - throw new Error( - 'Unexpected error: draft rate is missing a draftRevision.' - ) - } - - // Expect 2 connected rates - expect( - attemptToUpdateSubmittedRate.draftRevision.rateRevisions - ).toHaveLength(2) - - // Expect the first rates data not to have changed - expect( - attemptToUpdateSubmittedRate.draftRevision.rateRevisions[0].formData - ).toEqual(submittedExistingRate.revisions[0].formData) - // Expect the second rate to be connected and data not to be changed - expect( - attemptToUpdateSubmittedRate.draftRevision.rateRevisions[1].formData - ).toEqual(newSubmittedRate.revisions[0].formData) + expect(attemptToUpdateSubmittedRate).toBeInstanceOf(Error) }) }) diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts index 5d9d024dfe..717976ed87 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftContractWithRates.ts @@ -7,15 +7,11 @@ import type { RateFormEditableType, ContractFormEditableType, } from '../../domain-models/contractAndRates' -import type { StateCodeType } from '../../../../app-web/src/common-code/healthPlanFormDataType' import { includeDraftRates } from './prismaDraftContractHelpers' import { rateRevisionToDomainModel } from './prismaSharedContractRateHelpers' -import { isEqualData } from '../../resolvers/healthPlanPackage/contractAndRates/resolverHelpers' -import { - prismaUpdateRateFormDataFromDomain, - prismaUpdateContractFormDataFromDomain, - prismaRateCreateFormDataFromDomain, -} from './prismaContractRateAdaptors' +import type { UpdateDraftContractRatesArgsType } from './updateDraftContractRates' +import { updateDraftContractRatesInTransaction } from './updateDraftContractRates' +import { prismaUpdateContractFormDataFromDomain } from './prismaContractRateAdaptors' type UpdateContractArgsType = { contractID: string @@ -23,72 +19,68 @@ type UpdateContractArgsType = { rateFormDatas?: RateFormEditableType[] } -const sortRatesForUpdate = ( +// going down the old path, from the updateHPFD code, we construct the new +// call to the new updateContractDraftRates API. +// no rates will be linked here, only updated/created +function makeUpdateCommandsFromOldContract( + contractID: string, ratesFromDB: RateRevisionType[], ratesFromClient: RateFormEditableType[] -): { - upsertRates: RateFormEditableType[] - disconnectRates: { - rateID: string - revisionID: string - }[] -} => { - const upsertRates = [] - const disconnectRates = [] +): UpdateDraftContractRatesArgsType { + const updateArgs: UpdateDraftContractRatesArgsType = { + contractID, + rateUpdates: { + create: [], + update: [], + link: [], + unlink: [], + delete: [], + }, + } - // Find rates to create or update + // all rates are child rates in the old world, only create/update. + let thisPosition = 1 for (const clientRateData of ratesFromClient) { - // Find a matching rate revision id in the draftRatesFromDB array. const matchingDBRate = ratesFromDB.find( (dbRate) => dbRate.formData.rateID === clientRateData.id ) - // If there are no matching rates we push into createRates - if (!matchingDBRate) { - upsertRates.push({ - id: clientRateData.id, - ...clientRateData, + if (matchingDBRate) { + updateArgs.rateUpdates.update.push({ + rateID: matchingDBRate.rateID, + formData: clientRateData, + ratePosition: thisPosition, }) - continue - } - - // If a match is found then we deep compare to figure out if we need to update. - const isRateDataEqual = isEqualData( - matchingDBRate.formData, - clientRateData - ) - - // If rates are not equal we then make the update - if (!isRateDataEqual) { - upsertRates.push({ - id: clientRateData.id, - rateID: matchingDBRate.id, - ...clientRateData, + } else { + updateArgs.rateUpdates.create.push({ + formData: clientRateData, + ratePosition: thisPosition, }) } + thisPosition++ } - // Find rates to disconnect - for (const dbRateRev of ratesFromDB) { - //Find a matching rate revision id in the ratesFromClient - const matchingHPPRate = ratesFromClient.find( - (clientRateData) => clientRateData.id === dbRateRev.formData.rateID + // any rates that have been removed need to be deleted or unlinked. + for (const dbRate of ratesFromDB) { + const matchingClientRate = ratesFromClient.find( + (clientRateData) => dbRate.formData.rateID === clientRateData.id ) - // If convertedRateData does not contain the rate revision id from DB, we push these revisions id and rate id - // in disconnectRates - if (!matchingHPPRate && dbRateRev.formData.rateID) { - disconnectRates.push({ - rateID: dbRateRev.formData.rateID, - revisionID: dbRateRev.id, - }) + if (!matchingClientRate) { + // if it's been submitted before, it's unlink, if not, it's delete + if (dbRate.unlockInfo) { + updateArgs.rateUpdates.unlink.push({ + rateID: dbRate.rateID, + }) + } else { + updateArgs.rateUpdates.delete.push({ + rateID: dbRate.rateID, + }) + } } } - return { - upsertRates, - disconnectRates, - } + return updateArgs } // Update the given draft @@ -123,8 +115,6 @@ async function updateDraftContractWithRates( return new NotFoundError(err) } - const stateCode = currentContractRev.contract - .stateCode as StateCodeType const ratesFromDB: RateRevisionType[] = [] // Convert all rates from DB to domain model @@ -142,148 +132,34 @@ 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) { - // Current rate with the latest revision - let currentRate = undefined - - // 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, - orderBy: { - createdAt: 'desc', - }, - }, - }, - }) - } - - const contractsWithSharedRates = - rateFormData.packagesWithSharedRateCerts?.map( - (pkg) => ({ - id: pkg.packageId, - }) - ) ?? [] - - // If rate does not exist, we need to create a new rate. - if (!currentRate) { - const { latestStateRateCertNumber } = - await tx.state.update({ - data: { - latestStateRateCertNumber: { - increment: 1, - }, - }, - where: { - stateCode: stateCode, - }, - }) - - await tx.rateTable.create({ - data: { - id: rateFormData.id, - stateCode: stateCode, - stateNumber: latestStateRateCertNumber, - revisions: { - create: { - ...prismaRateCreateFormDataFromDomain( - rateFormData - ), - contractsWithSharedRateRevision: { - connect: contractsWithSharedRates, - }, - }, - }, - draftContractRevisions: { - connect: { - id: currentContractRev.id, - }, - }, - }, - }) - } else { - // If the current rate has no draft revisions, based form our find with revision with no submitInfoID - // then this is a submitted rate - const isSubmitted = currentRate.revisions.length === 0 - - await tx.rateTable.update({ - where: { - id: currentRate.id, - }, - data: { - // if rate is not submitted, we update the revision data, otherwise we only make the - // connection to the draft contract revision. - revisions: !isSubmitted - ? { - update: { - where: { - id: currentRate.revisions[0] - .id, - }, - data: { - ...prismaUpdateRateFormDataFromDomain( - rateFormData - ), - contractsWithSharedRateRevision: - { - set: contractsWithSharedRates, - }, - }, - }, - } - : undefined, - draftContractRevisions: { - connect: { - id: currentContractRev.id, - }, - }, - }, - }) - } + // call new style rate updates. + if (rateFormDatas) { + const rateUpdateCommands: UpdateDraftContractRatesArgsType = + makeUpdateCommandsFromOldContract( + contractID, + ratesFromDB, + rateFormDatas + ) + const rateUpdates = await updateDraftContractRatesInTransaction( + tx, + rateUpdateCommands + ) + if (rateUpdates instanceof Error) { + console.error( + 'failed to update new style rates in old style update', + rateUpdates + ) + return rateUpdates } } - // Then update resource, adjusting all simple fields and creating new linked resources for fields holding relationships to other day, + // Then update the contractRevision, adjusting all simple fields await tx.contractRevisionTable.update({ where: { id: currentContractRev.id, }, data: { ...prismaUpdateContractFormDataFromDomain(formData), - draftRates: { - disconnect: updateRates?.disconnectRates - ? updateRates.disconnectRates.map((rate) => ({ - id: rate.rateID, - })) - : [], - }, - contract: { - update: { - draftRateRevisions: { - disconnect: updateRates?.disconnectRates - ? updateRates.disconnectRates.map( - (rate) => ({ - id: rate.revisionID, - }) - ) - : [], - }, - }, - }, }, }) diff --git a/services/app-api/src/postgres/contractAndRates/updateDraftRate.test.ts b/services/app-api/src/postgres/contractAndRates/updateDraftRate.test.ts index 5e00f3c192..a187d120cc 100644 --- a/services/app-api/src/postgres/contractAndRates/updateDraftRate.test.ts +++ b/services/app-api/src/postgres/contractAndRates/updateDraftRate.test.ts @@ -1,11 +1,16 @@ import { sharedTestPrismaClient } from '../../testHelpers/storeHelpers' import { insertDraftRate } from './insertRate' -import { clearDocMetadata, must } from '../../testHelpers' +import { + clearDocMetadata, + mockInsertContractArgs, + must, +} from '../../testHelpers' import { updateDraftRate } from './updateDraftRate' import { PrismaClientValidationError } from '@prisma/client/runtime/library' import type { RateType } from '@prisma/client' import type { RateFormEditableType } from '../../domain-models/contractAndRates' +import { insertDraftContract } from './insertContract' describe('updateDraftRate', () => { afterEach(() => { @@ -17,8 +22,15 @@ describe('updateDraftRate', () => { const draftRateForm1 = { rateCertificationName: 'draftData' } + const draftContractData = mockInsertContractArgs({ + submissionDescription: 'one contract', + }) + const contract = must( + await insertDraftContract(client, draftContractData) + ) + const rate = must( - await insertDraftRate(client, { + await insertDraftRate(client, contract.id, { stateCode: 'MN', ...draftRateForm1, }) @@ -90,8 +102,15 @@ describe('updateDraftRate', () => { supportingDocuments: draftRateForm1.supportingDocuments, } + const draftContractData = mockInsertContractArgs({ + submissionDescription: 'one contract', + }) + const contract = must( + await insertDraftContract(client, draftContractData) + ) + const rate = must( - await insertDraftRate(client, { + await insertDraftRate(client, contract.id, { stateCode: 'MN', }) ) @@ -196,8 +215,15 @@ describe('updateDraftRate', () => { addtlActuaryContacts: draftRateForm1.addtlActuaryContacts, } + const draftContractData = mockInsertContractArgs({ + submissionDescription: 'one contract', + }) + const contract = must( + await insertDraftContract(client, draftContractData) + ) + const rate = must( - await insertDraftRate(client, { + await insertDraftRate(client, contract.id, { stateCode: 'MN', }) ) @@ -256,8 +282,15 @@ describe('updateDraftRate', () => { it('returns an error when invalid form data for rate type provided', async () => { jest.spyOn(console, 'error').mockImplementation() const client = await sharedTestPrismaClient() + const draftContractData = mockInsertContractArgs({ + submissionDescription: 'one contract', + }) + const contract = must( + await insertDraftContract(client, draftContractData) + ) + const newRate = must( - await insertDraftRate(client, { + await insertDraftRate(client, contract.id, { stateCode: 'MN', }) ) diff --git a/services/app-api/src/resolvers/configureResolvers.ts b/services/app-api/src/resolvers/configureResolvers.ts index 11a5e5ad72..2569daaf51 100644 --- a/services/app-api/src/resolvers/configureResolvers.ts +++ b/services/app-api/src/resolvers/configureResolvers.ts @@ -36,7 +36,10 @@ import { unlockRate } from './rate/unlockRate' import { submitRate } from './rate/submitRate' import { updateDraftContractRates } from './contract/updateDraftContractRates' import { contractResolver } from './contract/contractResolver' +import { contractRevisionResolver } from './contract/contractRevisionResolver' import { fetchContractResolver } from './contract/fetchContract' +import { submitContract } from './contract/submitContract' +import { rateRevisionResolver } from './rate/rateRevisionResolver' export function configureResolvers( store: Store, @@ -76,6 +79,12 @@ export function configureResolvers( emailParameterStore, launchDarkly ), + submitContract: submitContract( + store, + emailer, + emailParameterStore, + launchDarkly + ), unlockHealthPlanPackage: unlockHealthPlanPackageResolver( store, emailer, @@ -116,11 +125,22 @@ export function configureResolvers( } }, }, + SubmittableRevision: { + __resolveType(obj) { + if ('contract' in obj) { + return 'ContractRevision' + } else { + return 'RateRevision' + } + }, + }, StateUser: stateUserResolver, CMSUser: cmsUserResolver, HealthPlanPackage: healthPlanPackageResolver(store), Rate: rateResolver, - Contract: contractResolver(store), + RateRevision: rateRevisionResolver, + Contract: contractResolver(), + ContractRevision: contractRevisionResolver(store), } return resolvers diff --git a/services/app-api/src/resolvers/contract/contractResolver.ts b/services/app-api/src/resolvers/contract/contractResolver.ts index 1077e30ac3..6722f8c329 100644 --- a/services/app-api/src/resolvers/contract/contractResolver.ts +++ b/services/app-api/src/resolvers/contract/contractResolver.ts @@ -1,16 +1,22 @@ import statePrograms from '../../../../app-web/src/common-code/data/statePrograms.json' -import type { Resolvers } from '../../gen/gqlServer' -import { logError } from '../../logger' -import type { Store } from '../../postgres' +import type { Resolvers, SubmissionReason } from '../../gen/gqlServer' import { GraphQLError } from 'graphql' -import { setErrorAttributesOnActiveSpan } from '../attributeHelper' -import { packageName } from '../../../../app-web/src/common-code/healthPlanFormDataType' +import type { + ContractPackageSubmissionWithCauseType, + RateRevisionType, +} from '../../domain-models' -export function contractResolver(store: Store): Resolvers['Contract'] { +export function contractResolver(): Resolvers['Contract'] { return { initiallySubmittedAt(parent) { - // we're only working on drafts for now, this will need to change to - // look at the revisions when we expand + if (parent.packageSubmissions.length > 0) { + const firstSubmission = + parent.packageSubmissions[ + parent.packageSubmissions.length - 1 + ] + return firstSubmission.submitInfo.updatedAt + } + return null }, state(parent) { @@ -31,63 +37,67 @@ export function contractResolver(store: Store): Resolvers['Contract'] { } return state }, + packageSubmissions(parent) { + const gqlSubs: ContractPackageSubmissionWithCauseType[] = [] + for (let i = 0; i < parent.packageSubmissions.length; i++) { + const thisSub = parent.packageSubmissions[i] + let prevSub = undefined + if (i < parent.packageSubmissions.length - 1) { + prevSub = parent.packageSubmissions[i + 1] + } - draftRevision(parent) { - const programsForContractState = statePrograms.states - .find((state) => state.code === parent.stateCode) - ?.programs.filter((program) => program !== undefined) - const contractName = packageName( - parent.stateCode, - parent.stateNumber, - parent.draftRevision?.formData.programIDs ?? [], - programsForContractState ?? [] - ) + // determine the cause for this submission + let cause: SubmissionReason = 'CONTRACT_SUBMISSION' - return ( - { - ...parent.draftRevision, - contractName, - } || {} - ) - }, - draftRates: async (parent, _args, context) => { - const { span } = context - const rateDataArray = parent.draftRevision?.rateRevisions || [] + if ( + !thisSub.submittedRevisions.find( + (r) => r.id === thisSub.contractRevision.id + ) + ) { + // not a contract submission, this contract wasn't in the submitted bits + const connectedRateRevisionIDs = thisSub.rateRevisions.map( + (r) => r.id + ) + const submittedRate = thisSub.submittedRevisions.find((r) => + connectedRateRevisionIDs.includes(r.id) + ) - return rateDataArray.map(async (rateData) => { - if (rateData.formData.rateID === undefined) { - const errMessage = `rateID on ${rateData.id} is undefined` - logError('fetchContract', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) + if (!submittedRate) { + cause = 'RATE_UNLINK' + } else { + const thisSubmittedRate = + submittedRate as RateRevisionType + if (!prevSub) { + throw new Error( + 'Programming Error: a non-contract submission must have a previous contract submission' + ) + } + const previousRateRevisionIDs = + prevSub.rateRevisions.map((r) => r.rateID) + if ( + previousRateRevisionIDs.includes( + thisSubmittedRate.rateID + ) + ) { + cause = 'RATE_SUBMISSION' + } else { + cause = 'RATE_LINK' + } + } } - const rateResult = await store.findRateWithHistory( - rateData.formData.rateID - ) - - if (rateResult instanceof Error) { - const errMessage = `Could not find rate with id: ${rateData.id}. Message: ${rateResult.message}` - logError('fetchContract', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) + const gqlSub: ContractPackageSubmissionWithCauseType = { + cause, + submitInfo: thisSub.submitInfo, + submittedRevisions: thisSub.submittedRevisions, + contractRevision: thisSub.contractRevision, + rateRevisions: thisSub.rateRevisions, } - return rateResult - }) - }, - // not yet implemented, currently only working on drafts: - packageSubmissions() { - return [] + + gqlSubs.push(gqlSub) + } + + return gqlSubs }, } } diff --git a/services/app-api/src/resolvers/contract/contractRevisionResolver.ts b/services/app-api/src/resolvers/contract/contractRevisionResolver.ts new file mode 100644 index 0000000000..46f4acc257 --- /dev/null +++ b/services/app-api/src/resolvers/contract/contractRevisionResolver.ts @@ -0,0 +1,27 @@ +import { packageName } from '../../../../app-web/src/common-code/healthPlanFormDataType' +import type { ContractRevisionType } from '../../domain-models' +import type { Resolvers } from '../../gen/gqlServer' +import type { Store } from '../../postgres' + +export function contractRevisionResolver( + store: Store +): Resolvers['ContractRevision'] { + return { + contractName(parent: ContractRevisionType): string { + const stateCode = parent.contract.stateCode + const programsForContractState = store.findStatePrograms(stateCode) + if (programsForContractState instanceof Error) { + throw programsForContractState + } + + const contractName = packageName( + stateCode, + parent.contract.stateNumber, + parent.formData.programIDs, + programsForContractState ?? [] + ) + + return contractName + }, + } +} diff --git a/services/app-api/src/resolvers/contract/fetchContract.test.ts b/services/app-api/src/resolvers/contract/fetchContract.test.ts index 04499a70d5..87ab12eb69 100644 --- a/services/app-api/src/resolvers/contract/fetchContract.test.ts +++ b/services/app-api/src/resolvers/contract/fetchContract.test.ts @@ -1,11 +1,19 @@ import { constructTestPostgresServer, createAndUpdateTestHealthPlanPackage, + unlockTestHealthPlanPackage, } from '../../testHelpers/gqlHelpers' import FETCH_CONTRACT from '../../../../app-graphql/src/queries/fetchContract.graphql' import type { RateType } from '../../domain-models' -import { testStateUser } from '../../testHelpers/userHelpers' +import { testCMSUser, testStateUser } from '../../testHelpers/userHelpers' +import { + createAndUpdateTestContractWithoutRates, + fetchTestContract, + submitTestContract, +} from '../../testHelpers/gqlContractHelpers' +import { addNewRateToTestContract } from '../../testHelpers/gqlRateHelpers' +import { testLDService } from '../../testHelpers/launchDarklyHelpers' describe('fetchContract', () => { it('fetches the draft contract and a new child rate', async () => { @@ -34,6 +42,86 @@ describe('fetchContract', () => { expect(draftRate[0].stateCode).toBe('FL') }) + it('gets the right contract name', async () => { + const stateServer = await constructTestPostgresServer() + + const stateSubmission = + await createAndUpdateTestHealthPlanPackage(stateServer) + + const fetchDraftContractResult = await stateServer.executeOperation({ + query: FETCH_CONTRACT, + variables: { + input: { + contractID: stateSubmission.id, + }, + }, + }) + + expect(fetchDraftContractResult.errors).toBeUndefined() + + const draftContract = + fetchDraftContractResult.data?.fetchContract.contract.draftRevision + + expect(draftContract.contractName).toMatch(/MCR-FL-\d{4}-MMA/) + }) + + it('returns a stable initially submitted at', async () => { + const ldService = testLDService({ + 'link-rates': true, + }) + const stateServer = await constructTestPostgresServer({ + ldService, + }) + const cmsServer = await constructTestPostgresServer({ + ldService, + context: { + user: testCMSUser(), + }, + }) + + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + const draftA010 = await addNewRateToTestContract(stateServer, draftA0) + await addNewRateToTestContract(stateServer, draftA010) + + const unsubmitted = await fetchTestContract(stateServer, AID) + expect(unsubmitted.initiallySubmittedAt).toBeNull() + + const intiallySubmitted = await submitTestContract(stateServer, AID) + + await unlockTestHealthPlanPackage(cmsServer, AID, 'Unlock A.0') + await submitTestContract(stateServer, AID, 'Submit A.1') + + await unlockTestHealthPlanPackage(cmsServer, AID, 'Unlock A.1') + await submitTestContract(stateServer, AID, 'Submit A.2') + + await unlockTestHealthPlanPackage(cmsServer, AID, 'Unlock A.2') + await submitTestContract(stateServer, AID, 'Submit A.3') + + await unlockTestHealthPlanPackage(cmsServer, AID, 'Unlock A.3') + await submitTestContract(stateServer, AID, 'Submit A.4') + + const submittedMultiply = await fetchTestContract(stateServer, AID) + + expect(submittedMultiply.packageSubmissions).toHaveLength(5) + + expect(submittedMultiply.initiallySubmittedAt).toBeTruthy() + expect(submittedMultiply.initiallySubmittedAt).toEqual( + intiallySubmitted.initiallySubmittedAt + ) + + await unlockTestHealthPlanPackage(cmsServer, AID, 'Unlock A.4') + + const finallyUnlocked = await fetchTestContract(stateServer, AID) + expect(finallyUnlocked.packageSubmissions).toHaveLength(5) + + expect(finallyUnlocked.initiallySubmittedAt).toBeTruthy() + expect(finallyUnlocked.initiallySubmittedAt).toEqual( + intiallySubmitted.initiallySubmittedAt + ) + }) + it('errors if the wrong state user calls it', async () => { const stateServerFL = await constructTestPostgresServer() diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts new file mode 100644 index 0000000000..baf9d74ee0 --- /dev/null +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -0,0 +1,1095 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import UPDATE_DRAFT_CONTRACT_RATES from 'app-graphql/src/mutations/updateDraftContractRates.graphql' +import { + constructTestPostgresServer, + createAndUpdateTestHealthPlanPackage, + unlockTestHealthPlanPackage, + updateTestHealthPlanFormData, +} from '../../testHelpers/gqlHelpers' +import SUBMIT_CONTRACT from '../../../../app-graphql/src/mutations/submitContract.graphql' + +import { testCMSUser } from '../../testHelpers/userHelpers' +import type { + ContractRevision, + RateRevision, + SubmitContractInput, +} from '../../gen/gqlServer' +import { + createAndSubmitTestContractWithRate, + createAndUpdateTestContractWithoutRates, + fetchTestContract, + submitTestContract, +} from '../../testHelpers/gqlContractHelpers' +import { + addLinkedRateToRateInput, + addLinkedRateToTestContract, + addNewRateToTestContract, + fetchTestRateById, + updateRatesInputFromDraftContract, + updateTestDraftRatesOnContract, +} from '../../testHelpers/gqlRateHelpers' +import { testLDService } from '../../testHelpers/launchDarklyHelpers' +import { latestFormData } from '../../testHelpers/healthPlanPackageHelpers' + +describe('submitContract', () => { + it('submits a contract', async () => { + const stateServer = await constructTestPostgresServer() + + const draft = await createAndUpdateTestContractWithoutRates(stateServer) + const draftWithRates = await addNewRateToTestContract( + stateServer, + draft + ) + + const draftRates = draftWithRates.draftRates + + expect(draftRates).toHaveLength(1) + + const contract = await submitTestContract(stateServer, draft.id) + + expect(contract.draftRevision).toBeNull() + + expect(contract.packageSubmissions).toHaveLength(1) + + const sub = contract.packageSubmissions[0] + expect(sub.cause).toBe('CONTRACT_SUBMISSION') + expect(sub.submitInfo.updatedReason).toBe('Initial submission') + expect(sub.submittedRevisions).toHaveLength(2) + expect(sub.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const rateID = sub.rateRevisions[0].rateID + const rate = await fetchTestRateById(stateServer, rateID) + expect(rate.status).toBe('SUBMITTED') + }) + + it('handles a submission with a link', async () => { + const stateServer = await constructTestPostgresServer() + + const contract1 = await createAndSubmitTestContractWithRate(stateServer) + const rate1ID = contract1.packageSubmissions[0].rateRevisions[0].rateID + + const draft2 = + await createAndUpdateTestContractWithoutRates(stateServer) + await addLinkedRateToTestContract(stateServer, draft2, rate1ID) + const contract2 = await submitTestContract(stateServer, draft2.id) + + expect(contract2.draftRevision).toBeNull() + + expect(contract2.packageSubmissions).toHaveLength(1) + + const sub = contract2.packageSubmissions[0] + expect(sub.cause).toBe('CONTRACT_SUBMISSION') + expect(sub.submitInfo.updatedReason).toBe('Initial submission') + expect(sub.submittedRevisions).toHaveLength(1) + expect(sub.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(sub.rateRevisions).toHaveLength(1) + }) + + it('calls create twice in a row', async () => { + const stateServer = await constructTestPostgresServer() + + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + await addNewRateToTestContract(stateServer, draftA0) + await addNewRateToTestContract(stateServer, draftA0) + + const final = await fetchTestContract(stateServer, AID) + expect(final.draftRates).toHaveLength(1) + }) + + it('handles the first miro scenario', async () => { + const stateServer = await constructTestPostgresServer() + + // 1. Submit A0 with Rate1 and Rate2 + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + const draftA010 = await addNewRateToTestContract(stateServer, draftA0) + + await addNewRateToTestContract(stateServer, draftA010) + + const contractA0 = await submitTestContract(stateServer, AID) + const subA0 = contractA0.packageSubmissions[0] + const rate10 = subA0.rateRevisions[0] + const OneID = rate10.rateID + const rate20 = subA0.rateRevisions[1] + const TwoID = rate20.rateID + + // 2. Submit B0 with Rate1 and Rate3 + const draftB0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const draftB010 = await addLinkedRateToTestContract( + stateServer, + draftB0, + OneID + ) + await addNewRateToTestContract(stateServer, draftB010) + + const contractB0 = await submitTestContract(stateServer, draftB0.id) + const subB0 = contractB0.packageSubmissions[0] + const rate30 = subB0.rateRevisions[1] + const ThreeID = rate30.rateID + + expect(subB0.rateRevisions[0].rateID).toBe(OneID) + + // 3. Submit C0 with Rate20 and Rate40 + const draftC0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const draftC020 = await addLinkedRateToTestContract( + stateServer, + draftC0, + TwoID + ) + await addNewRateToTestContract(stateServer, draftC020) + + const contractC0 = await submitTestContract(stateServer, draftC0.id) + const subC0 = contractC0.packageSubmissions[0] + const rate40 = subC0.rateRevisions[1] + const FourID = rate40.rateID + expect(subC0.rateRevisions[0].rateID).toBe(TwoID) + + // 4. Submit D0, contract only + const draftD0 = await createAndUpdateTestHealthPlanPackage( + stateServer, + { + rateInfos: [], + submissionType: 'CONTRACT_ONLY', + addtlActuaryContacts: [], + addtlActuaryCommunicationPreference: undefined, + } + ) + const contractD0 = await submitTestContract(stateServer, draftD0.id) + + console.info(ThreeID, FourID, contractD0) + }) + + it('handles complex submission etc', async () => { + const ldService = testLDService({ + 'link-rates': true, + }) + + const stateServer = await constructTestPostgresServer({ + ldService, + }) + const cmsServer = await constructTestPostgresServer({ + ldService, + context: { + user: testCMSUser(), + }, + }) + + // 1. Submit A0 with Rate1 and Rate2 + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + const draftA010 = await addNewRateToTestContract(stateServer, draftA0, { + rateDateStart: '2001-01-01', + }) + + await addNewRateToTestContract(stateServer, draftA010, { + rateDateStart: '2002-01-01', + }) + + const contractA0 = await submitTestContract(stateServer, AID) + const subA0 = contractA0.packageSubmissions[0] + const rate10 = subA0.rateRevisions[0] + const OneID = rate10.rateID + const rate20 = subA0.rateRevisions[1] + const TwoID = rate20.rateID + + console.info('ONEID', OneID) + console.info('TWOID', TwoID) + + // 2. Submit B0 with Rate1 and Rate3 + const draftB0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const BID = draftB0.id + const draftB010 = await addNewRateToTestContract(stateServer, draftB0, { + rateDateStart: '2003-01-01', + }) + await addLinkedRateToTestContract(stateServer, draftB010, OneID) + + const contractB0 = await submitTestContract(stateServer, draftB0.id) + const subB0 = contractB0.packageSubmissions[0] + const rate30 = subB0.rateRevisions[0] + const ThreeID = rate30.rateID + console.info('THREEID', ThreeID) + + expect(subB0.rateRevisions[0].rateID).toBe(ThreeID) + expect(subB0.rateRevisions[1].rateID).toBe(OneID) + + // 3. Submit C0 with Rate20 and Rate40 + const draftC0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const CID = draftC0.id + const draftC020 = await addLinkedRateToTestContract( + stateServer, + draftC0, + TwoID + ) + await addNewRateToTestContract(stateServer, draftC020, { + rateDateStart: '2004-01-01', + }) + + const contractC0 = await submitTestContract(stateServer, draftC0.id) + const subC0 = contractC0.packageSubmissions[0] + const rate40 = subC0.rateRevisions[1] + const FourID = rate40.rateID + console.info('FOURID', FourID) + expect(subC0.rateRevisions[0].rateID).toBe(TwoID) + + // 4. Submit D0, contract only + const draftD0 = await createAndUpdateTestHealthPlanPackage( + stateServer, + { + rateInfos: [], + submissionType: 'CONTRACT_ONLY', + addtlActuaryContacts: [], + addtlActuaryCommunicationPreference: undefined, + } + ) + const DID = draftD0.id + const contractD0 = await submitTestContract(stateServer, draftD0.id) + + console.info(ThreeID, FourID, contractD0) + + // check on initial setup + const firstA = await fetchTestContract(stateServer, AID) + const firstB = await fetchTestContract(stateServer, BID) + const firstC = await fetchTestContract(stateServer, CID) + const firstD = await fetchTestContract(stateServer, DID) + + expect(firstA.packageSubmissions).toHaveLength(1) + expect(firstB.packageSubmissions).toHaveLength(1) + expect(firstC.packageSubmissions).toHaveLength(1) + expect(firstD.packageSubmissions).toHaveLength(1) + + // 5. resubmit A, 1, and 2. B and C will get new entries. + console.info('---- UNLOCK A.1 ----') + const unlockedA0Pkg = await unlockTestHealthPlanPackage( + cmsServer, + AID, + 'Unlock A.0' + ) + const a0FormData = latestFormData(unlockedA0Pkg) + const unlockedA0Contract = await fetchTestContract(stateServer, AID) + a0FormData.submissionDescription = 'DESC A1' + await updateTestHealthPlanFormData(stateServer, a0FormData) + const a0RatesUpdates = + updateRatesInputFromDraftContract(unlockedA0Contract) + expect(a0RatesUpdates.updatedRates[0].rateID).toBe(OneID) + expect(a0RatesUpdates.updatedRates[1].rateID).toBe(TwoID) + + if ( + !a0RatesUpdates.updatedRates[0].formData || + !a0RatesUpdates.updatedRates[1].formData + ) { + throw new Error('missing updates') + } + + expect(a0RatesUpdates.updatedRates[0].formData.rateDateStart).toBe( + '2001-01-01' + ) + expect(a0RatesUpdates.updatedRates[1].formData.rateDateStart).toBe( + '2002-01-01' + ) + + a0RatesUpdates.updatedRates[0].formData.rateDateStart = '2001-02-02' + a0RatesUpdates.updatedRates[1].formData.rateDateStart = '2002-02-02' + await updateTestDraftRatesOnContract(stateServer, a0RatesUpdates) + + console.info('---- SUBMIT A.1 ----') + await submitTestContract(stateServer, AID, 'Submit A.1') + + // Check second showing + const secondA = await fetchTestContract(stateServer, AID) + const secondB = await fetchTestContract(stateServer, BID) + const secondC = await fetchTestContract(stateServer, CID) + const secondD = await fetchTestContract(stateServer, DID) + + expect(secondA.packageSubmissions).toHaveLength(2) + expect(secondB.packageSubmissions).toHaveLength(2) + expect(secondC.packageSubmissions).toHaveLength(2) + expect(secondD.packageSubmissions).toHaveLength(1) + + expect(secondA.packageSubmissions[0].submitInfo.updatedReason).toBe( + 'Submit A.1' + ) + expect( + secondA.packageSubmissions[0].contractRevision.formData + .submissionDescription + ).toBe('DESC A1') + expect(secondA.packageSubmissions[1].submitInfo.updatedReason).toBe( + 'Initial submission' + ) + expect( + secondA.packageSubmissions[1].contractRevision.formData + .submissionDescription + ).toBe('An updated submission') + + // check B history + expect(secondB.packageSubmissions[1].submitInfo.updatedReason).toBe( + 'Initial submission' + ) + expect( + secondB.packageSubmissions[1].contractRevision.formData + .submissionDescription + ).toBe('An updated submission') + expect(secondB.packageSubmissions[1].rateRevisions).toHaveLength(2) + expect( + secondB.packageSubmissions[1].rateRevisions[0].formData + .rateDateStart + ).toBe('2003-01-01') + expect( + secondB.packageSubmissions[1].rateRevisions[1].formData + .rateDateStart + ).toBe('2001-01-01') + + expect(secondB.packageSubmissions[0].submitInfo.updatedReason).toBe( + 'Submit A.1' + ) + expect( + secondB.packageSubmissions[0].contractRevision.formData + .submissionDescription + ).toBe('An updated submission') + expect(secondB.packageSubmissions[0].rateRevisions).toHaveLength(2) + expect( + secondB.packageSubmissions[0].rateRevisions[0].formData + .rateDateStart + ).toBe('2003-01-01') + expect( + secondB.packageSubmissions[0].rateRevisions[1].formData + .rateDateStart + ).toBe('2001-02-02') + + // 6. resubmit B, add r4. Only B gets a new entry. + console.info('---- UNLOCK B.1 ----') + const unlockedB0Pkg = await unlockTestHealthPlanPackage( + cmsServer, + BID, + 'Unlock B.0' + ) + const b0FormData = latestFormData(unlockedB0Pkg) + const unlockedB0Contract = await fetchTestContract(stateServer, BID) + + b0FormData.submissionDescription = 'DESC B1' + await updateTestHealthPlanFormData(stateServer, b0FormData) + const b0RatesUpdates = + updateRatesInputFromDraftContract(unlockedB0Contract) + expect(b0RatesUpdates.updatedRates[0].type).toBe('UPDATE') + expect(b0RatesUpdates.updatedRates[0].rateID).toBe(ThreeID) + expect(b0RatesUpdates.updatedRates[1].type).toBe('LINK') + expect(b0RatesUpdates.updatedRates[1].rateID).toBe(OneID) + + if (!b0RatesUpdates.updatedRates[0].formData) { + throw new Error('missing updates') + } + + expect(b0RatesUpdates.updatedRates[0].formData.rateDateStart).toBe( + '2003-01-01' + ) + + b0RatesUpdates.updatedRates[0].formData.rateDateStart = '2003-02-02' + + const b0RatesUpdatesWith4 = addLinkedRateToRateInput( + b0RatesUpdates, + FourID + ) + + await updateTestDraftRatesOnContract(stateServer, b0RatesUpdatesWith4) + + console.info('---- SUBMIT B.1 ----') + await submitTestContract(stateServer, BID, 'Submit B.1') + + // Check third showing + const thirdA = await fetchTestContract(stateServer, AID) + const thirdB = await fetchTestContract(stateServer, BID) + const thirdC = await fetchTestContract(stateServer, CID) + const thirdD = await fetchTestContract(stateServer, DID) + + expect(thirdA.packageSubmissions).toHaveLength(2) + expect(thirdB.packageSubmissions).toHaveLength(3) + expect(thirdC.packageSubmissions).toHaveLength(2) + expect(thirdD.packageSubmissions).toHaveLength(1) + + // 7. Resubmit C, remove rate 2. B should also get an update. + const unlockedC0Pkg = await unlockTestHealthPlanPackage( + cmsServer, + CID, + 'Unlock C.0' + ) + const c0FormData = latestFormData(unlockedC0Pkg) + const unlockedC0Contract = await fetchTestContract(stateServer, CID) + + c0FormData.submissionDescription = 'DESC C1' + await updateTestHealthPlanFormData(stateServer, c0FormData) + const c0RatesUpdates = + updateRatesInputFromDraftContract(unlockedC0Contract) + expect(c0RatesUpdates.updatedRates[0].type).toBe('LINK') + expect(c0RatesUpdates.updatedRates[0].rateID).toBe(TwoID) + expect(c0RatesUpdates.updatedRates[1].type).toBe('UPDATE') + expect(c0RatesUpdates.updatedRates[1].rateID).toBe(FourID) + + if (!c0RatesUpdates.updatedRates[1].formData) { + throw new Error('missing updates') + } + + expect(c0RatesUpdates.updatedRates[1].formData.rateDateStart).toBe( + '2004-01-01' + ) + c0RatesUpdates.updatedRates[1].formData.rateDateStart = '2004-02-02' + + // remove linked 2.1 + c0RatesUpdates.updatedRates.splice(0, 1) + + await updateTestDraftRatesOnContract(stateServer, c0RatesUpdates) + + console.info('---- SUBMIT C.1 ----') + await submitTestContract(stateServer, CID, 'Submit C.1') + + // Check fourth showing + const fourthA = await fetchTestContract(stateServer, AID) + const fourthB = await fetchTestContract(stateServer, BID) + const fourthC = await fetchTestContract(stateServer, CID) + const fourthD = await fetchTestContract(stateServer, DID) + + expect(fourthA.packageSubmissions).toHaveLength(2) + expect(fourthB.packageSubmissions).toHaveLength(4) + expect(fourthC.packageSubmissions).toHaveLength(3) + expect(fourthD.packageSubmissions).toHaveLength(1) + + // check removal happened: + const lastCPackage = fourthC.packageSubmissions[0] + expect(lastCPackage.rateRevisions).toHaveLength(1) + expect(lastCPackage.rateRevisions[0].formData.rateDateStart).toBe( + '2004-02-02' + ) + + // Now check that all the histories are as expected + // Contract A. + const as1 = fourthA.packageSubmissions[0] + expect(as1.cause).toBe('CONTRACT_SUBMISSION') + expect(as1.submittedRevisions).toHaveLength(3) + expect(as1.submittedRevisions.map((r) => r.id).sort()).toEqual( + [as1.contractRevision.id] + .concat(as1.rateRevisions.map((r) => r.id)) + .sort() + ) + expect(as1.contractRevision.formData.submissionDescription).toBe( + 'DESC A1' + ) + expect(as1.rateRevisions[0].formData.rateDateStart).toBe('2001-02-02') + expect(as1.rateRevisions[1].formData.rateDateStart).toBe('2002-02-02') + + const as2 = fourthA.packageSubmissions[1] + expect(as2.cause).toBe('CONTRACT_SUBMISSION') + expect(as2.submittedRevisions).toHaveLength(3) + expect(as2.submittedRevisions.map((r) => r.id).sort()).toEqual( + [as2.contractRevision.id] + .concat(as2.rateRevisions.map((r) => r.id)) + .sort() + ) + expect(as2.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(as2.rateRevisions[0].formData.rateDateStart).toBe('2001-01-01') + expect(as2.rateRevisions[1].formData.rateDateStart).toBe('2002-01-01') + + // Contract B + const bs1 = fourthB.packageSubmissions[0] + expect(bs1.submittedRevisions).toHaveLength(2) + const bs1submittedContract = bs1 + .submittedRevisions[0] as ContractRevision + const bs1submittedRate = bs1.submittedRevisions[1] as RateRevision + expect(bs1submittedContract.formData.submissionDescription).toBe( + 'DESC C1' + ) + expect(bs1submittedRate.formData.rateDateStart).toBe('2004-02-02') + expect(bs1.contractRevision.formData.submissionDescription).toBe( + 'DESC B1' + ) + expect(bs1.rateRevisions[0].formData.rateDateStart).toBe('2003-02-02') + expect(bs1.rateRevisions[1].formData.rateDateStart).toBe('2001-02-02') + expect(bs1.rateRevisions[2].formData.rateDateStart).toBe('2004-02-02') + expect(bs1.cause).toBe('RATE_SUBMISSION') + + const bs2 = fourthB.packageSubmissions[1] + expect(bs2.submittedRevisions).toHaveLength(2) + const bs2submittedContract = bs2 + .submittedRevisions[0] as ContractRevision + const bs2submittedRate = bs2.submittedRevisions[1] as RateRevision + expect(bs2submittedContract.formData.submissionDescription).toBe( + 'DESC B1' + ) + expect(bs2submittedRate.formData.rateDateStart).toBe('2003-02-02') + expect(bs2.contractRevision.formData.submissionDescription).toBe( + 'DESC B1' + ) + expect(bs2.rateRevisions[0].formData.rateDateStart).toBe('2003-02-02') + expect(bs2.rateRevisions[1].formData.rateDateStart).toBe('2001-02-02') + expect(bs2.rateRevisions[2].formData.rateDateStart).toBe('2004-01-01') + expect(bs2.cause).toBe('CONTRACT_SUBMISSION') + + const bs3 = fourthB.packageSubmissions[2] + expect(bs3.submittedRevisions).toHaveLength(3) + const bs3submittedContract = bs3 + .submittedRevisions[0] as ContractRevision + const bs3submittedRate = bs3.submittedRevisions[1] as RateRevision + const bs3submittedRate2 = bs3.submittedRevisions[2] as RateRevision + expect(bs3submittedContract.formData.submissionDescription).toBe( + 'DESC A1' + ) + expect( + [bs3submittedRate, bs3submittedRate2] + .map((rr) => rr.formData.rateDateStart) + .sort() + ).toEqual(['2001-02-02', '2002-02-02']) + expect(bs3.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(bs3.rateRevisions[0].formData.rateDateStart).toBe('2003-01-01') + expect(bs3.rateRevisions[1].formData.rateDateStart).toBe('2001-02-02') + expect(bs3.cause).toBe('RATE_SUBMISSION') + + const bs4 = fourthB.packageSubmissions[3] + expect(bs4.submittedRevisions).toHaveLength(2) + const bs4submittedContract = bs4 + .submittedRevisions[0] as ContractRevision + const bs4submittedRate = bs4.submittedRevisions[1] as RateRevision + expect(bs4submittedContract.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(bs4submittedRate.formData.rateDateStart).toBe('2003-01-01') + expect(bs4.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(bs4.rateRevisions[0].formData.rateDateStart).toBe('2003-01-01') + expect(bs4.rateRevisions[1].formData.rateDateStart).toBe('2001-01-01') + expect(bs4.cause).toBe('CONTRACT_SUBMISSION') + + // C + const cs1 = fourthC.packageSubmissions[0] + expect(cs1.submittedRevisions).toHaveLength(2) + const cs1submittedContract = cs1 + .submittedRevisions[0] as ContractRevision + const cs1submittedRate = cs1.submittedRevisions[1] as RateRevision + expect(cs1submittedContract.formData.submissionDescription).toBe( + 'DESC C1' + ) + expect(cs1submittedRate.formData.rateDateStart).toBe('2004-02-02') + expect(cs1.contractRevision.formData.submissionDescription).toBe( + 'DESC C1' + ) + expect(cs1.rateRevisions[0].formData.rateDateStart).toBe('2004-02-02') + expect(cs1.rateRevisions).toHaveLength(1) + expect(cs1.cause).toBe('CONTRACT_SUBMISSION') + + const cs2 = fourthC.packageSubmissions[1] + expect(cs2.submittedRevisions).toHaveLength(3) + const cs2submittedContract = cs2 + .submittedRevisions[0] as ContractRevision + const cs2submittedRate = cs2.submittedRevisions[1] as RateRevision + const cs2submittedRate2 = cs2.submittedRevisions[2] as RateRevision + expect(cs2submittedContract.formData.submissionDescription).toBe( + 'DESC A1' + ) + expect( + [cs2submittedRate, cs2submittedRate2] + .map((rr) => rr.formData.rateDateStart) + .sort() + ).toEqual(['2001-02-02', '2002-02-02']) + expect(cs2.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(cs2.rateRevisions[0].formData.rateDateStart).toBe('2002-02-02') + expect(cs2.rateRevisions[1].formData.rateDateStart).toBe('2004-01-01') + expect(cs2.rateRevisions).toHaveLength(2) + expect(cs2.cause).toBe('RATE_SUBMISSION') + + const cs3 = fourthC.packageSubmissions[2] + expect(cs3.submittedRevisions).toHaveLength(2) + const cs3submittedContract = cs3 + .submittedRevisions[0] as ContractRevision + const cs3submittedRate = cs3.submittedRevisions[1] as RateRevision + expect(cs3submittedContract.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(cs3submittedRate.formData.rateDateStart).toBe('2004-01-01') + expect(cs3.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(cs3.rateRevisions[0].formData.rateDateStart).toBe('2002-01-01') + expect(cs3.rateRevisions[1].formData.rateDateStart).toBe('2004-01-01') + expect(cs3.rateRevisions).toHaveLength(2) + expect(cs3.cause).toBe('CONTRACT_SUBMISSION') + + // D + const ds1 = fourthD.packageSubmissions[0] + expect(ds1.submittedRevisions).toHaveLength(1) + const ds1submittedContract = ds1 + .submittedRevisions[0] as ContractRevision + expect(ds1submittedContract.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(ds1.contractRevision.formData.submissionDescription).toBe( + 'An updated submission' + ) + expect(ds1.rateRevisions).toHaveLength(0) + expect(ds1.cause).toBe('CONTRACT_SUBMISSION') + }) + + it('handles unlock and editing rates', async () => { + const stateServer = await constructTestPostgresServer() + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + }) + + console.info('1.') + // 1. Submit A0 with Rate1 and Rate2 + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + const draftA010 = await addNewRateToTestContract(stateServer, draftA0) + + await addNewRateToTestContract(stateServer, draftA010) + + const contractA0 = await submitTestContract(stateServer, AID) + const subA0 = contractA0.packageSubmissions[0] + const rate10 = subA0.rateRevisions[0] + const OneID = rate10.rateID + + console.info('2.') + // 2. Submit B0 with Rate1 and Rate3 + const draftB0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const draftB010 = await addLinkedRateToTestContract( + stateServer, + draftB0, + OneID + ) + await addNewRateToTestContract(stateServer, draftB010) + + const contractB0 = await submitTestContract(stateServer, draftB0.id) + const subB0 = contractB0.packageSubmissions[0] + + expect(subB0.rateRevisions[0].rateID).toBe(OneID) + + // unlock B, rate 3 should unlock, rate 1 should not. + await unlockTestHealthPlanPackage( + cmsServer, + contractB0.id, + 'test unlock' + ) + + const unlockedB = await fetchTestContract(stateServer, contractB0.id) + if (!unlockedB.draftRates) { + throw new Error('no draft rates') + } + + expect(unlockedB.draftRates?.length).toBe(2) // this feels like it shouldnt work, probably pulling from the old rev. + + const rate1 = unlockedB.draftRates[0] + const rate3 = unlockedB.draftRates[1] + + expect(rate1.status).toBe('SUBMITTED') + expect(rate3.status).toBe('UNLOCKED') + + const rateUpdateInput = updateRatesInputFromDraftContract(unlockedB) + expect(rateUpdateInput.updatedRates).toHaveLength(2) + expect(rateUpdateInput.updatedRates[0].type).toBe('LINK') + expect(rateUpdateInput.updatedRates[1].type).toBe('UPDATE') + if (!rateUpdateInput.updatedRates[1].formData) { + throw new Error('should be set') + } + + rateUpdateInput.updatedRates[1].formData.rateDateCertified = + '2000-01-22' + + const updatedB = await updateTestDraftRatesOnContract( + stateServer, + rateUpdateInput + ) + expect( + updatedB.draftRates![1].draftRevision?.formData.rateDateCertified + ).toBe('2000-01-22') + }) + + it('checks parent rates on update', async () => { + const stateServer = await constructTestPostgresServer() + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + }) + + console.info('1.') + // 1. Submit A0 with Rate1 and Rate2 + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + const draftA010 = await addNewRateToTestContract(stateServer, draftA0, { + rateDateStart: '2021-01-01', + }) + + await addNewRateToTestContract(stateServer, draftA010, { + rateDateStart: '2022-02-02', + }) + + const contractA0 = await submitTestContract(stateServer, AID) + const subA0 = contractA0.packageSubmissions[0] + const rate10 = subA0.rateRevisions[0] + const OneID = rate10.rateID + + console.info('2.') + // 2. Submit B0 with Rate1 and Rate3 + const draftB0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const draftB010 = await addLinkedRateToTestContract( + stateServer, + draftB0, + OneID + ) + await addNewRateToTestContract(stateServer, draftB010, { + rateDateStart: '2023-03-03', + }) + + const contractB0 = await submitTestContract(stateServer, draftB0.id) + const subB0 = contractB0.packageSubmissions[0] + + expect(subB0.rateRevisions[0].rateID).toBe(OneID) + + // rate1 then rate3 + expect( + subB0.rateRevisions.map((r) => r.formData.rateDateStart) + ).toEqual(['2021-01-01', '2023-03-03']) + + // unlock A + await unlockTestHealthPlanPackage(cmsServer, contractA0.id, 'unlock a') + // unlock B, rate 3 should unlock, rate 1 should not. + await unlockTestHealthPlanPackage( + cmsServer, + contractB0.id, + 'test unlock' + ) + const unlockedB = await fetchTestContract(stateServer, contractB0.id) + if (!unlockedB.draftRates) { + throw new Error('no draft rates') + } + + expect(unlockedB.draftRates?.length).toBe(2) + expect( + unlockedB.draftRates.map( + (r) => r.draftRevision!.formData.rateDateStart + ) + ).toEqual(['2021-01-01', '2023-03-03']) + + const rate1 = unlockedB.draftRates[0] + const rate3 = unlockedB.draftRates[1] + + expect(rate1.status).toBe('UNLOCKED') + expect(rate3.status).toBe('UNLOCKED') + + const rateUpdateInput = updateRatesInputFromDraftContract(unlockedB) + expect(rateUpdateInput.updatedRates).toHaveLength(2) + expect(rateUpdateInput.updatedRates[0].type).toBe('LINK') + expect(rateUpdateInput.updatedRates[1].type).toBe('UPDATE') + if (!rateUpdateInput.updatedRates[1].formData) { + throw new Error('should be set') + } + + // attempt to update a link + rateUpdateInput.updatedRates[0].type = 'UPDATE' + rateUpdateInput.updatedRates[0].formData = + rateUpdateInput.updatedRates[1].formData + + rateUpdateInput.updatedRates[1].formData.rateDateCertified = + '2000-01-22' + + const updateResult = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: rateUpdateInput, + }, + }) + + expect(updateResult.errors).toBeDefined() + if (!updateResult.errors) { + throw new Error('must be defined') + } + + expect(updateResult.errors[0].message).toMatch( + /^Attempted to update a rate that is not a child of this contract/ + ) + }) + + it('can remove a child unlocked rate', async () => { + //TODO: make a child rate, submit and unlock, then remove it. + const stateServer = await constructTestPostgresServer() + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + }) + + console.info('1.') + // 1. Submit A0 with Rate1 and Rate2 + const draftA0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const AID = draftA0.id + const draftA010 = await addNewRateToTestContract(stateServer, draftA0, { + rateDateStart: '2021-01-01', + }) + + await addNewRateToTestContract(stateServer, draftA010, { + rateDateStart: '2022-02-02', + }) + + const contractA0 = await submitTestContract(stateServer, AID) + const subA0 = contractA0.packageSubmissions[0] + const rate10 = subA0.rateRevisions[0] + const OneID = rate10.rateID + + console.info('2.') + // 2. Submit B0 with Rate1 and Rate3 + const draftB0 = + await createAndUpdateTestContractWithoutRates(stateServer) + const draftB010 = await addLinkedRateToTestContract( + stateServer, + draftB0, + OneID + ) + await addNewRateToTestContract(stateServer, draftB010, { + rateDateStart: '2023-03-03', + }) + + const contractB0 = await submitTestContract(stateServer, draftB0.id) + const subB0 = contractB0.packageSubmissions[0] + + expect(subB0.rateRevisions[0].rateID).toBe(OneID) + + // rate1 then rate3 + expect( + subB0.rateRevisions.map((r) => r.formData.rateDateStart) + ).toEqual(['2021-01-01', '2023-03-03']) + + // unlock A + await unlockTestHealthPlanPackage(cmsServer, contractA0.id, 'unlock a') + // unlock B, rate 3 should unlock, rate 1 should not. + await unlockTestHealthPlanPackage( + cmsServer, + contractB0.id, + 'test unlock' + ) + + const unlockedB = await fetchTestContract(stateServer, contractB0.id) + if (!unlockedB.draftRates) { + throw new Error('no draft rates') + } + + expect(unlockedB.draftRates?.length).toBe(2) + expect( + unlockedB.draftRates.map( + (r) => r.draftRevision!.formData.rateDateStart + ) + ).toEqual(['2021-01-01', '2023-03-03']) + + const rate1 = unlockedB.draftRates[0] + const rate3 = unlockedB.draftRates[1] + + expect(rate1.status).toBe('UNLOCKED') + expect(rate3.status).toBe('UNLOCKED') + + const rateUpdateInput = updateRatesInputFromDraftContract(unlockedB) + expect(rateUpdateInput.updatedRates).toHaveLength(2) + expect(rateUpdateInput.updatedRates[0].type).toBe('LINK') + expect(rateUpdateInput.updatedRates[1].type).toBe('UPDATE') + if (!rateUpdateInput.updatedRates[1].formData) { + throw new Error('should be set') + } + + // attempt to update a link + rateUpdateInput.updatedRates[0].type = 'UPDATE' + rateUpdateInput.updatedRates[0].formData = + rateUpdateInput.updatedRates[1].formData + + rateUpdateInput.updatedRates[1].formData.rateDateCertified = + '2000-01-22' + + const updateResult = await stateServer.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input: rateUpdateInput, + }, + }) + + expect(updateResult.errors).toBeDefined() + if (!updateResult.errors) { + throw new Error('must be defined') + } + + expect(updateResult.errors[0].message).toMatch( + /^Attempted to update a rate that is not a child of this contract/ + ) + }) + + it('returns an error if a CMS user attempts to call submitContract', async () => { + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + }) + + const input: SubmitContractInput = { + contractID: 'fake-id-12345', + submittedReason: 'Test cms user calling state user func', + } + + const res = await cmsServer.executeOperation({ + query: SUBMIT_CONTRACT, + variables: { input }, + }) + + expect(res.errors).toBeDefined() + expect(res.errors && res.errors[0].message).toBe( + 'user not authorized to fetch state data' + ) + }) + + it('tests actions from the diagram that Jason made', async () => { + const ldService = testLDService({ + 'link-rates': true, + 'rate-edit-unlock': true, + }) + const stateServer = await constructTestPostgresServer({ + ldService, + }) + + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + ldService, + }) + + // make draft contract 1.1 with rate A.1 and submit + const S1 = await createAndSubmitTestContractWithRate(stateServer) + expect(S1.status).toBe('SUBMITTED') + + // unlock S1 so state can add Rate B + // TODO: validate on package submissions not revs + const unlockS1Res = await unlockTestHealthPlanPackage( + cmsServer, + S1.id, + 'You are missing a rate' + ) + const S1Unlocked = await fetchTestContract(stateServer, unlockS1Res.id) + expect(S1Unlocked.status).toBe('UNLOCKED') + expect(S1Unlocked.draftRevision?.unlockInfo?.updatedReason).toBe( + 'You are missing a rate' + ) + + // add rateB and submit + const S2draft = await fetchTestContract(stateServer, unlockS1Res.id) + const S2draftWithRateB = await addNewRateToTestContract( + stateServer, + S2draft + ) + + const S2 = await submitTestContract( + stateServer, + S2draftWithRateB.id, + 'Added rate B to the submission' + ) + + expect(S2.status).toBe('RESUBMITTED') + expect(S2.packageSubmissions).toHaveLength(2) + expect( + S2.packageSubmissions[0].contractRevision.submitInfo?.updatedReason + ).toBe('Added rate B to the submission') + + // We now have contract 1.2 with A.1 and B.1 + + // TODO: Finish this test once we have support for unlocking rates. + + // // unlock rate A and update it + // const rateAID = S2.packageSubmissions[0].rateRevisions[0].rateID + // console.info(`unlocking rate ${rateAID}`) + // const unlockRateARes = await cmsServer.executeOperation({ + // query: UNLOCK_RATE, + // variables: { + // input: { + // rateID: rateAID, + // unlockedReason: 'Unlocking Rate A for update', + // }, + // }, + // }) + + // const rateAUnlockData = unlockRateARes.data?.unlockRate.rate as Rate + // expect(unlockRateARes.errors).toBeUndefined() + // expect(rateAUnlockData.status).toBe('UNLOCKED') + // expect(rateAUnlockData.draftRevision?.unlockInfo?.updatedReason).toBe( + // 'Unlocking Rate A for update' + // ) + + // // make changes to Rate A and re-submit for Rate A.2 + // // TODO: this uses Prisma directly, we want to use updateRate resolver + // // once we have one + // const updateRateA2res = await updateTestRate(rateAID, { + // rateDateStart: new Date(Date.UTC(2024, 2, 1)), + // rateDateEnd: new Date(Date.UTC(2025, 1, 31)), + // rateDateCertified: new Date(Date.UTC(2024, 1, 31)), + // }) + // const S3Res = await stateServer.executeOperation({ + // query: SUBMIT_RATE, + // variables: { + // input: { + // rateID: updateRateA2res.id, + // }, + // }, + // }) + // expect(S3Res.errors).toBeUndefined() + + // const S3 = await fetchTestContract(stateServer, S2.id) + // expect(S3.packageSubmissions).toHaveLength(2) // we have contract revision 2 + + // // Unlock contract and update it + // await unlockTestHealthPlanPackage( + // cmsServer, + // S2.id, + // 'Unlocking to update contract to give us rev 3' + // ) + + // const unlockedS3 = await fetchTestContract(stateServer, S3.id) + // expect(unlockedS3.status).toBe('UNLOCKED') + + // await updateTestHealthPlanPackage(stateServer, unlockedS3.id, { + // contractDocuments: [ + // { + // name: 'new-doc-to-support', + // s3URL: 'testS3URL', + // sha256: 'fakesha12345', + // }, + // ], + // }) + + // const S4 = await submitTestContract( + // stateServer, + // unlockedS3.id, + // 'Added the new document' + // ) + + // expect(S4.packageSubmissions).toHaveLength(3) // we have contract revision 3 + + // // TODO: Unlock RateB and unlink RateA + // // TODO: Update & Resubmit Rate B.2 + }) +}) diff --git a/services/app-api/src/resolvers/contract/submitContract.ts b/services/app-api/src/resolvers/contract/submitContract.ts new file mode 100644 index 0000000000..6dded7137f --- /dev/null +++ b/services/app-api/src/resolvers/contract/submitContract.ts @@ -0,0 +1,45 @@ +import type { Emailer } from '../../emailer' +import type { MutationResolvers } from '../../gen/gqlServer' +import type { LDService } from '../../launchDarkly/launchDarkly' +import type { EmailParameterStore } from '../../parameterStore' +import type { Store } from '../../postgres' +import { submitHealthPlanPackageResolver } from '../healthPlanPackage' + +export function submitContract( + store: Store, + emailer: Emailer, + emailParameterStore: EmailParameterStore, + launchDarkly: LDService +): MutationResolvers['submitContract'] { + return async (parent, { input }, context) => { + // For some reason the types for resolvers are not actually callable? + const submitHPPResolver = submitHealthPlanPackageResolver( + store, + emailer, + emailParameterStore, + launchDarkly + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any + + await submitHPPResolver( + parent, + { + input: { + pkgID: input.contractID, + submittedReason: input.submittedReason, + }, + }, + context + ) + + const contract = await store.findContractWithHistory(input.contractID) + + if (contract instanceof Error) { + throw contract + } + + return { + contract, + } + } +} diff --git a/services/app-api/src/resolvers/contract/updateDraftContractRates.test.ts b/services/app-api/src/resolvers/contract/updateDraftContractRates.test.ts index 398ac0f704..f86864086e 100644 --- a/services/app-api/src/resolvers/contract/updateDraftContractRates.test.ts +++ b/services/app-api/src/resolvers/contract/updateDraftContractRates.test.ts @@ -910,7 +910,7 @@ describe('updateDraftContractRates', () => { } expect(result.errors[0].message).toContain( - 'Attempted to update a rate that is not a DRAFT' + 'Attempted to update a rate that is not a child of this contract' ) expect(result.errors[0].extensions?.code).toBe('BAD_USER_INPUT') // TODO: This test must be updated to account for CHILDREN diff --git a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts index 0046437da8..064e6855c8 100644 --- a/services/app-api/src/resolvers/contract/updateDraftContractRates.ts +++ b/services/app-api/src/resolvers/contract/updateDraftContractRates.ts @@ -12,6 +12,7 @@ import { rateFormDataSchema } from '../../domain-models/contractAndRates' import { ForbiddenError, UserInputError } from 'apollo-server-core' import { z } from 'zod' import type { UpdateDraftContractRatesArgsType } from '../../postgres/contractAndRates/updateDraftContractRates' +import { generateRateCertificationName } from '../rate/generateRateCertificationName' // Zod schemas to parse the updatedRates param since the types are not fully defined in GQL // CREATE / UPDATE / LINK @@ -88,6 +89,20 @@ function updateDraftContractRates( }) } + const statePrograms = store.findStatePrograms(contract.stateCode) + if (statePrograms instanceof Error) { + const errMessage = `Couldn't find programs for state ${contract.stateCode}. Message: ${statePrograms.message}` + logError('updateDraftContractRates', errMessage) + setErrorAttributesOnActiveSpan(errMessage, span) + + throw new GraphQLError(errMessage, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + cause: 'DB_ERROR', + }, + }) + } + // AUTHORIZATION // Only callable by a state user from this state if (isStateUser(user)) { @@ -142,12 +157,27 @@ function updateDraftContractRates( } // walk through the new rate list one by one - // TODO set the position in the list correctly // any rates that aren't in this list are unlinked (or deleted?) const knownRateIDs = draftRates.map((r) => r.id) + let thisPosition = 1 for (const rateUpdate of parsedUpdates) { if (rateUpdate.type === 'CREATE') { - rateUpdates.create.push({ formData: rateUpdate.formData }) + + // set rateName for now https://jiraent.cms.gov/browse/MCR-4012 + const rateName = generateRateCertificationName( + rateUpdate.formData, + contract.stateCode, + contract.stateNumber, + statePrograms, + ) + + rateUpdates.create.push({ + formData: { + ...rateUpdate.formData, + rateCertificationName: rateName, + }, + ratePosition: thisPosition, + }) } if (rateUpdate.type === 'UPDATE') { @@ -176,42 +206,40 @@ function updateDraftContractRates( throw new Error(errmsg) } - if (rateToUpdate.status !== 'DRAFT') { + if (!(rateToUpdate.status === 'DRAFT' || rateToUpdate.status === 'UNLOCKED')) { // 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: ' + + 'Attempted to update a rate that is not editable: ' + 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) - // } + if (rateToUpdate.parentContractID !== 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) } + // set rateName for now https://jiraent.cms.gov/browse/MCR-4012 + const rateName = generateRateCertificationName( + rateUpdate.formData, + contract.stateCode, + contract.stateNumber, + statePrograms, + ) + rateUpdates.update.push({ rateID: rateUpdate.rateID, - formData: rateUpdate.formData, + formData: { + ...rateUpdate.formData, + rateCertificationName: rateName, + }, + ratePosition: thisPosition, }) } @@ -219,43 +247,53 @@ function updateDraftContractRates( const knownRateIDX = knownRateIDs.indexOf(rateUpdate.rateID) if (knownRateIDX !== -1) { knownRateIDs.splice(knownRateIDX, 1) - continue - } + // we still pass all links down to the db, position might have changed? + rateUpdates.link.push({ + rateID: rateUpdate.rateID, + ratePosition: thisPosition, + }) + } else { + + // linked rates must exist and not be DRAFT + const rateToLink = await store.findRateWithHistory( + rateUpdate.rateID + ) + if (rateToLink instanceof Error) { + if (rateToLink instanceof NotFoundError) { + const errmsg = + 'Attempting to link a rate that does not exist: ' + + rateUpdate.rateID + logError('updateDraftContractRates', errmsg) + setErrorAttributesOnActiveSpan(errmsg, span) + throw new UserInputError(errmsg) + } - // linked rates must exist and not be DRAFT - const rateToLink = await store.findRateWithHistory( - rateUpdate.rateID - ) - if (rateToLink instanceof Error) { - if (rateToLink instanceof NotFoundError) { const errmsg = - 'Attempting to link a rate that does not exist: ' + + 'Unexpected Error: couldnt fetch the linking rate: ' + rateUpdate.rateID logError('updateDraftContractRates', errmsg) setErrorAttributesOnActiveSpan(errmsg, span) - throw new UserInputError(errmsg) + throw new Error(errmsg) } - const errmsg = - 'Unexpected Error: couldnt fetch the linking rate: ' + - rateUpdate.rateID - logError('updateDraftContractRates', errmsg) - setErrorAttributesOnActiveSpan(errmsg, span) - throw new Error(errmsg) - } + if (rateToLink.status === 'DRAFT') { + const errmsg = + 'Attempted to link a rate that has never been submitted: ' + + rateUpdate.rateID + logError('updateDraftContractRates', errmsg) + setErrorAttributesOnActiveSpan(errmsg, span) + throw new UserInputError(errmsg) + } - if (rateToLink.status === 'DRAFT') { - const errmsg = - 'Attempted to link a rate that has never been submitted: ' + - rateUpdate.rateID - logError('updateDraftContractRates', errmsg) - setErrorAttributesOnActiveSpan(errmsg, span) - throw new UserInputError(errmsg) + // this is a new link, actually link them. + rateUpdates.link.push({ + rateID: rateUpdate.rateID, + ratePosition: thisPosition, + }) } - - // this is a new link, actually link them. - rateUpdates.link.push({ rateID: rateUpdate.rateID }) } + + thisPosition++ } // we've gone through the existing rates, anything we didn't see we should remove diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index ded13f352f..392b76c176 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -46,7 +46,6 @@ import type { Span } from '@opentelemetry/api' import type { PackageStatusType, RateFormEditableType, - RateType, } from '../../domain-models/contractAndRates' export const SubmissionErrorCodes = ['INCOMPLETE', 'INVALID'] as const @@ -368,51 +367,6 @@ export function submitHealthPlanPackageResolver( // From this point forward we use updateResult instead of contractWithHistory because it is now old data. - // If there are rates, submit those first - if (updateResult.draftRevision.rateRevisions.length > 0) { - const ratePromises: Promise[] = [] - updateResult.draftRevision.rateRevisions.forEach((rateRev) => { - ratePromises.push( - store.submitRate({ - rateRevisionID: rateRev.id, - submittedByUserID: user.id, - submittedReason: updateInfo.updatedReason, - }) - ) - }) - - const submitRatesResult = await Promise.all(ratePromises) - // if any of the promises reject, which shouldn't happen b/c we don't throw... - if (submitRatesResult instanceof Error) { - const errMessage = `Failed to submit contract revision's rates with ID: ${contractRevisionID}; ${submitRatesResult.message}` - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } - const submitRateErrors: Error[] = submitRatesResult.filter( - (res) => res instanceof Error - ) as Error[] - if (submitRateErrors.length > 0) { - console.error('Errors submitting Rates: ', submitRateErrors) - const errMessage = `Failed to submit contract revision's rates with ID: ${contractRevisionID}; ${submitRateErrors.map( - (err) => err.message - )}` - logError('submitHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } - } - // then submit the contract! const submitContractResult = await store.submitContract({ contractID: updateResult.id, diff --git a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts index 9a972f3339..64d5624144 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/unlockHealthPlanPackage.ts @@ -80,53 +80,6 @@ export function unlockHealthPlanPackageResolver( }) } - // unlock all the revisions, then unlock the contract, in a transaction. - const currentRateRevIDs = contract.revisions[0].rateRevisions.map( - (rr) => rr.id - ) - const unlockRatePromises = [] - for (const rateRevisionID of currentRateRevIDs) { - const resPromise = store.unlockRate({ - rateRevisionID, - unlockReason: unlockedReason, - unlockedByUserID: user.id, - }) - - unlockRatePromises.push(resPromise) - } - - const unlockRateResults = await Promise.all(unlockRatePromises) - // if any of the promises reject, which shouldn't happen b/c we don't throw... - if (unlockRateResults instanceof Error) { - const errMessage = `Failed to unlock contract rates with ID: ${contract.id}; ${unlockRateResults.message}` - logError('unlockHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } - - const unlockRateErrors: Error[] = unlockRateResults.filter( - (res) => res instanceof Error - ) as Error[] - if (unlockRateErrors.length > 0) { - console.error('Errors unlocking Rates: ', unlockRateErrors) - const errMessage = `Failed to submit contract revision's rates with ID: ${ - contract.id - }; ${unlockRateErrors.map((err) => err.message)}` - logError('unlockHealthPlanPackage', errMessage) - setErrorAttributesOnActiveSpan(errMessage, span) - throw new GraphQLError(errMessage, { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - cause: 'DB_ERROR', - }, - }) - } - // Now, unlock the contract! const unlockContractResult = await store.unlockContract({ contractID: contract.id, diff --git a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts index 2a5bae9978..5b876147e4 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts @@ -165,6 +165,12 @@ export function updateHealthPlanFormDataResolver( }) } + // if Link Rates, we don't pass any rates into updateDraftContractWithRates, that's + // being called separately with the new UI now. + const rateUpdatesToPass = featureFlags?.['link-rates'] + ? undefined + : updateRateFormDatas + // Update contract draft revision const updateResult = await store.updateDraftContractWithRates({ contractID: input.pkgID, @@ -192,7 +198,7 @@ export function updateHealthPlanFormDataResolver( } ), }, - rateFormDatas: updateRateFormDatas, + rateFormDatas: rateUpdatesToPass, }) if (updateResult instanceof Error) { diff --git a/services/app-api/src/resolvers/rate/fetchRate.test.ts b/services/app-api/src/resolvers/rate/fetchRate.test.ts index dc96685784..1d6a38708d 100644 --- a/services/app-api/src/resolvers/rate/fetchRate.test.ts +++ b/services/app-api/src/resolvers/rate/fetchRate.test.ts @@ -5,13 +5,9 @@ import { defaultFloridaRateProgram, } from '../../testHelpers/gqlHelpers' import { testCMSUser } from '../../testHelpers/userHelpers' -import { - createAndSubmitTestRate, - submitTestRate, - unlockTestRate, - updateTestRate, -} from '../../testHelpers' +import { submitTestRate, updateTestRate } from '../../testHelpers' import { v4 as uuidv4 } from 'uuid' +import { createSubmitAndUnlockTestRate } from '../../testHelpers/gqlRateHelpers' describe('fetchRate', () => { const ldService = testLDService({ @@ -30,43 +26,9 @@ describe('fetchRate', () => { ldService, }) - const initialRate = () => ({ - id: uuidv4(), - rateType: 'NEW' as const, - rateDateStart: new Date(Date.UTC(2025, 5, 1)), - rateDateEnd: new Date(Date.UTC(2026, 4, 30)), - rateDateCertified: new Date(Date.UTC(2025, 3, 15)), - rateDocuments: [ - { - name: 'rateDocument.pdf', - s3URL: 'fakeS3URL', - sha256: 'fakesha', - }, - ], - supportingDocuments: [], - rateProgramIDs: [defaultFloridaRateProgram().id], - actuaryContacts: [ - { - name: 'test name', - titleRole: 'test title', - email: 'email@example.com', - actuarialFirm: 'MERCER' as const, - actuarialFirmOther: '', - }, - ], - actuaryCommunicationPreference: 'OACT_TO_ACTUARY' as const, - packagesWithSharedRateCerts: [], - }) - - // First, submit and unlock a rate - const submittedRate = await createAndSubmitTestRate(stateServer, { - stateCode: 'FL', - ...initialRate(), - }) - await unlockTestRate( - cmsServer, - submittedRate.id, - 'Unlock to edit an existing rate' + const submittedRate = await createSubmitAndUnlockTestRate( + stateServer, + cmsServer ) // editrate with new data and resubmit @@ -97,10 +59,10 @@ describe('fetchRate', () => { ) // the initial submit data is correct expect(resubmittedRate.revisions[1].formData.rateDateStart).toBe( - '2025-06-01' + '2024-01-01' ) expect(resubmittedRate.revisions[1].formData.rateDateEnd).toBe( - '2026-05-30' + '2025-01-01' ) expect(resubmittedRate.revisions[1].submitInfo?.updatedReason).toBe( 'Initial submission' @@ -147,21 +109,14 @@ describe('fetchRate', () => { }) // First, create new rate and unlock to edit it - const submittedInitial = await createAndSubmitTestRate(server, { - stateCode: 'MS', - rateDateStart: new Date(Date.UTC(2030, 1, 1)), - rateDateEnd: new Date(Date.UTC(2031, 1, 1)), - }) - - const existingRate = await unlockTestRate( - cmsServer, - submittedInitial.id, - 'Unlock to edit add a new rate' + const submittedInitial = await createSubmitAndUnlockTestRate( + server, + cmsServer ) // add new rate - const firstRateID = existingRate.id - expect(existingRate.revisions).toHaveLength(1) + const firstRateID = submittedInitial.id + expect(submittedInitial.revisions).toHaveLength(1) const updatedRate = await updateTestRate(submittedInitial.id, { ...initialRateInfos(), rateDateStart: new Date(Date.UTC(2034, 1, 1)), @@ -175,7 +130,7 @@ describe('fetchRate', () => { 'Resubmit with an additional rate' ) - // fetch and check rate 1 which was resubmitted with no changese + // fetch and check rate 1 which was resubmitted with no changes expect(firstRateID).toBe(resubmittedRate.id) // first rate ID should be unchanged const result1 = await cmsServer.executeOperation({ @@ -200,10 +155,10 @@ describe('fetchRate', () => { // check that initial rate is correct expect(resubmittedRate1.revisions[1].formData.rateDateStart).toBe( - '2030-02-01' + '2024-01-01' ) expect(resubmittedRate1.revisions[1].formData.rateDateEnd).toBe( - '2031-02-01' + '2025-01-01' ) expect(resubmittedRate1.revisions[1].submitInfo.updatedReason).toBe( 'Initial submission' @@ -221,16 +176,13 @@ describe('fetchRate', () => { ldService, }) - const submittedRate = await createAndSubmitTestRate(server) - - const unlockRate = await unlockTestRate( - cmsServer, - submittedRate.id, - 'Unlock to edit a rate' + const submittedRate = await createSubmitAndUnlockTestRate( + server, + cmsServer ) const input = { - rateID: unlockRate.id, + rateID: submittedRate.id, } // fetch rate diff --git a/services/app-api/src/resolvers/rate/indexRates.test.ts b/services/app-api/src/resolvers/rate/indexRates.test.ts index 554c1b95da..a021f0df78 100644 --- a/services/app-api/src/resolvers/rate/indexRates.test.ts +++ b/services/app-api/src/resolvers/rate/indexRates.test.ts @@ -1,23 +1,24 @@ -import { v4 as uuidv4 } from 'uuid' import { testLDService } from '../../testHelpers/launchDarklyHelpers' import INDEX_RATES from '../../../../app-graphql/src/queries/indexRates.graphql' import { constructTestPostgresServer, - defaultFloridaRateProgram, + createAndUpdateTestHealthPlanPackage, } from '../../testHelpers/gqlHelpers' -import type { StateCodeType } from '../../../../app-web/src/common-code/healthPlanFormDataType' import type { RateEdge, Rate } from '../../gen/gqlServer' import { testCMSUser, testStateUser } from '../../testHelpers/userHelpers' import { formatGQLDate } from 'app-web/src/common-code/dateHelpers' import { submitTestRate, - createAndSubmitTestRate, - createTestRate, unlockTestRate, updateTestRate, createAndSubmitTestContract, } from '../../testHelpers' +import { + createAndSubmitTestContractWithRate, + submitTestContract, + createAndUpdateTestContractWithRate, +} from '../../testHelpers/gqlContractHelpers' describe('indexRates', () => { const ldService = testLDService({ @@ -33,9 +34,14 @@ describe('indexRates', () => { }, ldService, }) - // first, submit 2 rates - const submit1 = await createAndSubmitTestRate(stateServer) - const submit2 = await createAndSubmitTestRate(stateServer) + + const contract1 = await createAndSubmitTestContractWithRate(stateServer) + const contract2 = await createAndSubmitTestContractWithRate(stateServer) + + const submit1ID = + contract1.packageSubmissions[0].rateRevisions[0].rateID + const submit2ID = + contract2.packageSubmissions[0].rateRevisions[0].rateID // index rates const result = await cmsServer.executeOperation({ @@ -44,7 +50,7 @@ describe('indexRates', () => { expect(result.data).toBeDefined() const ratesIndex = result.data?.indexRates - const testRateIDs = [submit1.id, submit2.id] + const testRateIDs = [submit1ID, submit2ID] expect(result.errors).toBeUndefined() const matchedTestRates: Rate[] = ratesIndex.edges @@ -58,14 +64,23 @@ describe('indexRates', () => { it('does not return rates still in initial draft', async () => { const cmsUser = testCMSUser() + const stateServer = await constructTestPostgresServer({ ldService }) const cmsServer = await constructTestPostgresServer({ context: { user: cmsUser, }, }) + + const contract1 = await createAndUpdateTestContractWithRate(stateServer) + const contract2 = await createAndUpdateTestContractWithRate(stateServer) + + if (!contract1.draftRates || !contract2.draftRates) { + throw new Error('no draft rates') + } + // First, create new submissions - const draft1 = await createTestRate() - const draft2 = await createTestRate() + const draft1 = contract1.draftRates[0] + const draft2 = contract2.draftRates[0] // index rates const result = await cmsServer.executeOperation({ @@ -136,61 +151,31 @@ describe('indexRates', () => { ldService, }) - const florida: StateCodeType = 'FL' - const initialRateInfos = () => ({ - id: uuidv4(), - rateType: 'NEW' as const, - rateDateStart: new Date(Date.UTC(2025, 5, 1)), - rateDateEnd: new Date(Date.UTC(2026, 4, 30)), - rateDateCertified: new Date(Date.UTC(2025, 3, 15)), - stateCode: florida, - rateDocuments: [ - { - name: 'rateDocument.pdf', - s3URL: 'fakeS3URL', - sha256: 'fakesha', - }, - ], - supportingDocuments: [], - rateProgramIDs: [defaultFloridaRateProgram().id], - actuaryContacts: [ - { - name: 'test name', - titleRole: 'test title', - email: 'email@example.com', - actuarialFirm: 'MERCER' as const, - actuarialFirmOther: '', - }, - ], - actuaryCommunicationPreference: 'OACT_TO_ACTUARY' as const, - packagesWithSharedRateCerts: [], - }) + const contract1 = await createAndSubmitTestContractWithRate(server) + const contract2 = await createAndSubmitTestContractWithRate(server) - // First, create and submit new rates - const firstRate = await createAndSubmitTestRate(server, { - ...initialRateInfos(), - }) - const secondRate = await createAndSubmitTestRate(server, { - ...initialRateInfos(), - }) + const firstRateID = + contract1.packageSubmissions[0].rateRevisions[0].rateID + const secondRateID = + contract2.packageSubmissions[0].rateRevisions[0].rateID // Unlock one to be rate edited in place const firstRateUnlocked = await unlockTestRate( cmsServer, - firstRate.id, + firstRateID, 'Unlock to edit an existing rate' ) const secondRateUnlocked = await unlockTestRate( cmsServer, - secondRate.id, + secondRateID, 'Unlock to edit an existing rate' ) // update one with a new rate start and end date const existingFormData = firstRateUnlocked.draftRevision?.formData expect(existingFormData).toBeDefined() - await updateTestRate(firstRate.id, { + await updateTestRate(firstRateID, { rateDateStart: new Date(Date.UTC(2025, 1, 1)), rateDateEnd: new Date(Date.UTC(2027, 1, 1)), }) @@ -198,21 +183,20 @@ describe('indexRates', () => { // update the other with additional new rate const existingFormData2 = secondRateUnlocked.draftRevision?.formData expect(existingFormData2).toBeDefined() - const newRate = await createAndSubmitTestRate(server, { - ...initialRateInfos(), - rateDateStart: new Date(Date.UTC(2030, 1, 1)), - rateDateEnd: new Date(Date.UTC(2030, 12, 1)), - }) + + const contract3 = await createAndSubmitTestContractWithRate(server) + const newRateID = + contract3.packageSubmissions[0].rateRevisions[0].rateID // resubmit const firstRateResubmitted = await submitTestRate( server, - firstRate.id, + firstRateID, 'Resubmit with edited rate description' ) const secondRateResubmitted = await submitTestRate( server, - secondRate.id, + secondRateID, 'Resubmit with an additional rate added' ) @@ -233,7 +217,7 @@ describe('indexRates', () => { return test.id == secondRateResubmitted.id }) const newlyAdded = rates.find((test: Rate) => { - return test.id === newRate.id + return test.id === newRateID }) if (!resubmittedWithEdits || !resubmittedUnchanged || !newlyAdded) { @@ -253,10 +237,10 @@ describe('indexRates', () => { resubmittedWithEdits.revisions[0].submitInfo?.updatedReason ).toBe('Resubmit with edited rate description') expect(resubmittedWithEdits.revisions[1].formData.rateDateStart).toBe( - formatGQLDate(initialRateInfos().rateDateStart) + '2024-01-01' ) expect(resubmittedWithEdits.revisions[1].formData.rateDateEnd).toBe( - formatGQLDate(initialRateInfos().rateDateEnd) + '2025-01-01' ) expect( resubmittedWithEdits.revisions[1].submitInfo?.updatedReason @@ -265,10 +249,10 @@ describe('indexRates', () => { // check unchanged rate most recent revision and previous expect(resubmittedUnchanged.revisions).toHaveLength(2) expect(resubmittedUnchanged.revisions[0].formData.rateDateStart).toBe( - formatGQLDate(initialRateInfos().rateDateStart) + '2024-01-01' ) expect(resubmittedUnchanged.revisions[0].formData.rateDateEnd).toBe( - formatGQLDate(initialRateInfos().rateDateEnd) + '2025-01-01' ) expect( resubmittedUnchanged.revisions[0].submitInfo?.updatedReason @@ -279,20 +263,18 @@ describe('indexRates', () => { ).toBe('Initial submission') expect(resubmittedUnchanged.revisions[1].formData.rateDateStart).toBe( - formatGQLDate(initialRateInfos().rateDateStart) + '2024-01-01' ) expect(resubmittedUnchanged.revisions[1].formData.rateDateEnd).toBe( - formatGQLDate(initialRateInfos().rateDateEnd) + '2025-01-01' ) // check newly added rate expect(newlyAdded.revisions).toHaveLength(1) expect(newlyAdded.revisions[0].formData.rateDateStart).toBe( - formatGQLDate(new Date(Date.UTC(2030, 1, 1))) - ) - expect(newlyAdded.revisions[0].formData.rateDateEnd).toBe( - formatGQLDate(new Date(Date.UTC(2030, 12, 1))) + '2024-01-01' ) + expect(newlyAdded.revisions[0].formData.rateDateEnd).toBe('2025-01-01') }) it('synthesizes the right statuses as a rate is submitted/unlocked/etc', async () => { @@ -307,16 +289,23 @@ describe('indexRates', () => { }) // First, create new submissions - const submittedRate = await createAndSubmitTestRate(server) - const unlockedRate = await createAndSubmitTestRate(server) - const relockedRate = await createAndSubmitTestRate(server) + const contract1 = await createAndSubmitTestContractWithRate(server) + const contract2 = await createAndSubmitTestContractWithRate(server) + const contract3 = await createAndSubmitTestContractWithRate(server) + + const submittedRateID = + contract1.packageSubmissions[0].rateRevisions[0].rateID + const unlockedRateID = + contract2.packageSubmissions[0].rateRevisions[0].rateID + const relockedRateID = + contract3.packageSubmissions[0].rateRevisions[0].rateID // unlock two - await unlockTestRate(cmsServer, unlockedRate.id, 'Test reason') - await unlockTestRate(cmsServer, relockedRate.id, 'Test reason') + await unlockTestRate(cmsServer, unlockedRateID, 'Test reason') + await unlockTestRate(cmsServer, relockedRateID, 'Test reason') // resubmit one - await submitTestRate(server, relockedRate.id, 'Test first resubmission') + await submitTestRate(server, relockedRateID, 'Test first resubmission') // index rates const result = await cmsServer.executeOperation({ @@ -326,7 +315,7 @@ describe('indexRates', () => { expect(result.errors).toBeUndefined() // pull out test related rates and order them - const testRateIDs = [submittedRate.id, unlockedRate.id, relockedRate.id] + const testRateIDs = [submittedRateID, unlockedRateID, relockedRateID] const testRates: Rate[] = ratesIndex.edges .map((edge: RateEdge) => edge.node) @@ -358,18 +347,22 @@ describe('indexRates', () => { }) // First, create new rates - const submittedRate2 = await createAndSubmitTestRate(server) - const unlockedRate2 = await createAndSubmitTestRate(server) - const relockedRate2 = await createAndSubmitTestRate(server) + const contract1 = await createAndSubmitTestContractWithRate(server) + const contract2 = await createAndSubmitTestContractWithRate(server) + const contract3 = await createAndSubmitTestContractWithRate(server) + + const submittedRate2 = contract1.packageSubmissions[0].rateRevisions[0] + const unlockedRate2 = contract2.packageSubmissions[0].rateRevisions[0] + const relockedRate2 = contract3.packageSubmissions[0].rateRevisions[0] // unlock two - await unlockTestRate(cmsServer, unlockedRate2.id, 'Test reason') - await unlockTestRate(cmsServer, relockedRate2.id, 'Test reason') + await unlockTestRate(cmsServer, unlockedRate2.rateID, 'Test reason') + await unlockTestRate(cmsServer, relockedRate2.rateID, 'Test reason') // resubmit one await submitTestRate( server, - relockedRate2.id, + relockedRate2.rateID, 'Test first resubmission' ) @@ -381,9 +374,9 @@ describe('indexRates', () => { const ratesIndex = result.data?.indexRates expect(result.errors).toBeUndefined() - const submittedRateID = submittedRate2.id - const unlockedRateID = unlockedRate2.id - const resubmittedRateID = relockedRate2.id + const submittedRateID = submittedRate2.rateID + const unlockedRateID = unlockedRate2.rateID + const resubmittedRateID = relockedRate2.rateID if (!submittedRateID || !unlockedRateID || !resubmittedRateID) { throw new Error('Missing Rate ID') @@ -447,16 +440,21 @@ describe('indexRates', () => { }, ldService, }) + // submit packages from two different states - const defaultState1 = await createAndSubmitTestRate(stateServer) - const defaultState2 = await createAndSubmitTestRate(stateServer) - const draft = await createTestRate() + const contract1 = await createAndSubmitTestContractWithRate(stateServer) + const contract2 = await createAndSubmitTestContractWithRate(stateServer) - const otherState1 = await submitTestRate( + const pkg3 = await createAndUpdateTestHealthPlanPackage( otherStateServer, - draft.id, - 'submitted reason' + {}, + 'VA' ) + const contract3 = await submitTestContract(otherStateServer, pkg3.id) + + const defaultState1 = contract1.packageSubmissions[0].rateRevisions[0] + const defaultState2 = contract2.packageSubmissions[0].rateRevisions[0] + const otherState1 = contract3.packageSubmissions[0].rateRevisions[0] // index rates const result = await cmsServer.executeOperation({ @@ -472,9 +470,11 @@ describe('indexRates', () => { const defaultStateRates: Rate[] = [] const otherStateRates: Rate[] = [] allRates.forEach((rate) => { - if ([defaultState1.id, defaultState2.id].includes(rate.id)) { + if ( + [defaultState1.rateID, defaultState2.rateID].includes(rate.id) + ) { defaultStateRates.push(rate) - } else if (otherState1.id === rate.id) { + } else if (otherState1.rateID === rate.id) { otherStateRates.push(rate) } return diff --git a/services/app-api/src/resolvers/rate/rateRevisionResolver.ts b/services/app-api/src/resolvers/rate/rateRevisionResolver.ts new file mode 100644 index 0000000000..16cf2a4946 --- /dev/null +++ b/services/app-api/src/resolvers/rate/rateRevisionResolver.ts @@ -0,0 +1,7 @@ +import type { Resolvers } from '../../gen/gqlServer' + +export const rateRevisionResolver: Resolvers['RateRevision'] = { + contractRevisions(parent) { + return parent.contractRevisions || [] + }, +} diff --git a/services/app-api/src/resolvers/rate/submitRate.test.ts b/services/app-api/src/resolvers/rate/submitRate.test.ts index da0cad7862..65d3bf6116 100644 --- a/services/app-api/src/resolvers/rate/submitRate.test.ts +++ b/services/app-api/src/resolvers/rate/submitRate.test.ts @@ -8,12 +8,9 @@ import { testStateUser, testCMSUser } from '../../testHelpers/userHelpers' import SUBMIT_RATE from '../../../../app-graphql/src/mutations/submitRate.graphql' import FETCH_RATE from '../../../../app-graphql/src/queries/fetchRate.graphql' import UNLOCK_RATE from '../../../../app-graphql/src/mutations/unlockRate.graphql' -import { - createTestRate, - submitTestRate, - updateTestRate, -} from '../../testHelpers' +import { submitTestRate, updateTestRate } from '../../testHelpers' import SUBMIT_HEALTH_PLAN_PACKAGE from '../../../../app-graphql/src/mutations/submitHealthPlanPackage.graphql' +import { createSubmitAndUnlockTestRate } from '../../testHelpers/gqlRateHelpers' describe('submitRate', () => { const ldService = testLDService({ @@ -30,12 +27,19 @@ describe('submitRate', () => { ldService, }) - // createRate with full data - const draftRate = await createTestRate() + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + ldService, + }) + + const rate = await createSubmitAndUnlockTestRate(stateServer, cmsServer) + const fetchDraftRate = await stateServer.executeOperation({ query: FETCH_RATE, variables: { - input: { rateID: draftRate.id }, + input: { rateID: rate.id }, }, }) @@ -47,7 +51,7 @@ describe('submitRate', () => { query: SUBMIT_RATE, variables: { input: { - rateID: draftRate.id, + rateID: rate.id, }, }, }) @@ -61,7 +65,7 @@ describe('submitRate', () => { // expect rate data to be returned expect(submittedRate).toBeDefined() // expect status to be submitted. - expect(submittedRate.status).toBe('SUBMITTED') + expect(submittedRate.status).toBe('RESUBMITTED') // expect formData to be the same expect(submittedRateFormData).toEqual(draftFormData) }) @@ -75,29 +79,36 @@ describe('submitRate', () => { ldService, }) - const draftRate = await createTestRate() + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + ldService, + }) + + const rate = await createSubmitAndUnlockTestRate(stateServer, cmsServer) const fetchDraftRate = await stateServer.executeOperation({ query: FETCH_RATE, variables: { - input: { rateID: draftRate.id }, + input: { rateID: rate.id }, }, }) - const draftFormData = draftRate.draftRevision?.formData + const draftFormData = rate.draftRevision?.formData // expect draft rate created in contract to exist expect(fetchDraftRate.errors).toBeUndefined() expect(draftFormData).toBeDefined() // update rate - await updateTestRate(draftRate.id, { + await updateTestRate(rate.id, { rateDateStart: new Date(Date.UTC(2025, 1, 1)), }) const submittedRate = await submitTestRate( stateServer, - draftRate.id, + rate.id, 'Submit with edited rate description' ) @@ -106,7 +117,7 @@ describe('submitRate', () => { // expect rate data to be returned expect(submittedRate).toBeDefined() // expect status to be submitted. - expect(submittedRate.status).toBe('SUBMITTED') + expect(submittedRate.status).toBe('RESUBMITTED') // expect formData to NOT be the same expect(submittedRateFormData.rateDateStart).not.toEqual( draftFormData?.rateDateStart @@ -122,7 +133,17 @@ describe('submitRate', () => { ldService, }) - const draftRate = await createTestRate() + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + ldService, + }) + + const draftRate = await createSubmitAndUnlockTestRate( + stateServer, + cmsServer + ) const fetchDraftRate = await stateServer.executeOperation({ query: FETCH_RATE, @@ -155,7 +176,7 @@ describe('submitRate', () => { // expect rate data to be returned expect(submittedRate).toBeDefined() // expect status to be submitted. - expect(submittedRate.status).toBe('SUBMITTED') + expect(submittedRate.status).toBe('RESUBMITTED') // expect formData to be the same expect(submittedRateFormData).toEqual(draftFormData) }) @@ -251,7 +272,17 @@ describe('submitRate', () => { }), }) - const draftRate = await createTestRate() + const cmsServer = await constructTestPostgresServer({ + context: { + user: testCMSUser(), + }, + ldService, + }) + + const draftRate = await createSubmitAndUnlockTestRate( + stateServer, + cmsServer + ) const fetchDraftRate = await stateServer.executeOperation({ query: FETCH_RATE, diff --git a/services/app-api/src/resolvers/rate/unlockRate.test.ts b/services/app-api/src/resolvers/rate/unlockRate.test.ts index c50b7d615e..9224c03a4d 100644 --- a/services/app-api/src/resolvers/rate/unlockRate.test.ts +++ b/services/app-api/src/resolvers/rate/unlockRate.test.ts @@ -2,8 +2,9 @@ import { constructTestPostgresServer } from '../../testHelpers/gqlHelpers' import UNLOCK_RATE from '../../../../app-graphql/src/mutations/unlockRate.graphql' import { testCMSUser } from '../../testHelpers/userHelpers' import { expectToBeDefined } from '../../testHelpers/assertionHelpers' -import { createAndSubmitTestRate } from '../../testHelpers/gqlRateHelpers' import { testLDService } from '../../testHelpers/launchDarklyHelpers' +import { createSubmitAndUnlockTestRate } from '../../testHelpers/gqlRateHelpers' +import { createAndSubmitTestContractWithRate } from '../../testHelpers/gqlContractHelpers' describe(`unlockRate`, () => { const ldService = testLDService({ @@ -21,23 +22,23 @@ describe(`unlockRate`, () => { }) // Create and unlock a rate - const rate = await createAndSubmitTestRate(stateServer) - const rateID = rate.id - const unlockedReason = 'Super duper good reason.' - const unlockResult = await cmsServer.executeOperation({ - query: UNLOCK_RATE, - variables: { - input: { - rateID, - unlockedReason, - }, - }, - }) + const updatedRate = await createSubmitAndUnlockTestRate( + stateServer, + cmsServer + ) - const updatedRate = unlockResult.data?.unlockRate.rate expect(updatedRate.status).toBe('UNLOCKED') - expect(updatedRate.draftRevision.unlockInfo.updatedReason).toEqual( - unlockedReason + + if (!updatedRate.draftRevision) { + throw new Error('no draftrate') + } + + if (!updatedRate.draftRevision.unlockInfo) { + throw new Error('no unlockinfo') + } + + expect(updatedRate.draftRevision.unlockInfo.updatedReason).toBe( + 'test unlock' ) }) @@ -51,20 +52,7 @@ describe(`unlockRate`, () => { }) // Create a rate - const rate = await createAndSubmitTestRate(stateServer) - - // Unlock the rate once - const unlockResult1 = await cmsServer.executeOperation({ - query: UNLOCK_RATE, - variables: { - input: { - rateID: rate.id, - unlockedReason: 'Super duper good reason.', - }, - }, - }) - - expect(unlockResult1.errors).toBeUndefined() + const rate = await createSubmitAndUnlockTestRate(stateServer, cmsServer) // Try to unlock the rate again const unlockResult2 = await cmsServer.executeOperation({ @@ -85,8 +73,10 @@ describe(`unlockRate`, () => { it('returns unauthorized error for state user', async () => { const stateServer = await constructTestPostgresServer({ ldService }) + + const contract = await createAndSubmitTestContractWithRate(stateServer) // Create a rate - const rate = await createAndSubmitTestRate(stateServer) + const rate = contract.packageSubmissions[0].rateRevisions[0] // Unlock the rate const unlockResult = await stateServer.executeOperation({ diff --git a/services/app-api/src/testHelpers/contractDataMocks.ts b/services/app-api/src/testHelpers/contractDataMocks.ts index df58119826..07506ec522 100644 --- a/services/app-api/src/testHelpers/contractDataMocks.ts +++ b/services/app-api/src/testHelpers/contractDataMocks.ts @@ -49,6 +49,7 @@ const mockContractData = ( mccrsID: null, stateCode: 'MN', stateNumber: 111, + draftRates: [], revisions: [], ...contract, } @@ -84,6 +85,7 @@ const mockContractRevision = ( ...defaultContractData(), ...contract, }, + relatedSubmisions: [], createdAt: new Date(), updatedAt: new Date(), submitInfo: { diff --git a/services/app-api/src/testHelpers/emailerHelpers.ts b/services/app-api/src/testHelpers/emailerHelpers.ts index c7ca93d00f..d012f5d698 100644 --- a/services/app-api/src/testHelpers/emailerHelpers.ts +++ b/services/app-api/src/testHelpers/emailerHelpers.ts @@ -195,12 +195,7 @@ const mockContractRev = ( rateRevisions: [ { id: '12345', - rate: { - id: 'rate-id', - stateCode: 'MN', - stateNumber: 3, - createdAt: new Date(11 / 27 / 2023), - }, + rateID: '6789', submitInfo: undefined, unlockInfo: undefined, createdAt: new Date(11 / 27 / 2023), diff --git a/services/app-api/src/testHelpers/gqlContractHelpers.ts b/services/app-api/src/testHelpers/gqlContractHelpers.ts index 961f8d59f9..f6c33a66c2 100644 --- a/services/app-api/src/testHelpers/gqlContractHelpers.ts +++ b/services/app-api/src/testHelpers/gqlContractHelpers.ts @@ -1,22 +1,33 @@ +import FETCH_CONTRACT from '../../../app-graphql/src/queries/fetchContract.graphql' +import SUBMIT_CONTRACT from '../../../app-graphql/src/mutations/submitContract.graphql' import { findStatePrograms } from '../postgres' import type { InsertContractArgsType } from '../postgres/contractAndRates/insertContract' import { must } from './assertionHelpers' -import { defaultFloridaProgram } from './gqlHelpers' +import { + createTestHealthPlanPackage, + defaultFloridaProgram, + updateTestHealthPlanFormData, +} from './gqlHelpers' import { mockInsertContractArgs, mockContractData } from './contractDataMocks' import { sharedTestPrismaClient } from './storeHelpers' import { insertDraftContract } from '../postgres/contractAndRates/insertContract' import type { ContractType } from '../domain-models' +import type { ApolloServer } from 'apollo-server-lambda' +import type { Contract } from '../gen/gqlServer' +import { latestFormData } from './healthPlanPackageHelpers' +import type { StateCodeType } from 'app-web/src/common-code/healthPlanFormDataType' +import { addNewRateToTestContract } from './gqlRateHelpers' const createAndSubmitTestContract = async ( contractData?: InsertContractArgsType ): Promise => { const contract = await createTestContract(contractData) - return await must(submitTestContract(contract)) + return await must(submitTestContractWithDB(contract)) } -const submitTestContract = async ( +const submitTestContractWithDB = async ( contractData?: Partial ): Promise => { const prismaClient = await sharedTestPrismaClient() @@ -40,6 +51,65 @@ const submitTestContract = async ( return must(await insertDraftContract(prismaClient, draftContractData)) } +async function submitTestContract( + server: ApolloServer, + contractID: string, + submittedReason?: string +): Promise { + const result = await server.executeOperation({ + query: SUBMIT_CONTRACT, + variables: { + input: { + contractID: contractID, + submittedReason: submittedReason, + }, + }, + }) + + if (result.errors) { + throw new Error( + `submitTestContract query failed with errors ${result.errors}` + ) + } + + if (!result.data) { + throw new Error('submitTestContract returned nothing') + } + + return result.data.submitContract.contract +} + +async function createAndSubmitTestContractWithRate( + server: ApolloServer +): Promise { + const draft = await createAndUpdateTestContractWithRate(server) + + return await submitTestContract(server, draft.id) +} + +async function fetchTestContract( + server: ApolloServer, + contractID: string +): Promise { + const input = { contractID } + const result = await server.executeOperation({ + query: FETCH_CONTRACT, + variables: { input }, + }) + + if (result.errors) { + throw new Error( + `fetchTestContract query failed with errors ${result.errors}` + ) + } + + if (!result.data) { + throw new Error('fetchTestContract returned nothing') + } + + return result.data.fetchContract.contract +} + // USING PRISMA DIRECTLY BELOW --- we have no createContract resolver yet, but we have integration tests needing the workflows const createTestContract = async ( contractData?: Partial @@ -65,4 +135,70 @@ const createTestContract = async ( return must(await insertDraftContract(prismaClient, draftContractData)) } -export { createTestContract, createAndSubmitTestContract } +async function createAndUpdateTestContractWithRate( + server: ApolloServer +): Promise { + const draft = await createAndUpdateTestContractWithoutRates(server) + return await addNewRateToTestContract(server, draft) +} + +const createAndUpdateTestContractWithoutRates = async ( + server: ApolloServer, + stateCode?: StateCodeType +): Promise => { + const pkg = await createTestHealthPlanPackage(server, stateCode) + const draft = latestFormData(pkg) + + ;(draft.submissionType = 'CONTRACT_AND_RATES' as const), + (draft.submissionDescription = 'An updated submission') + draft.stateContacts = [ + { + name: 'test name', + titleRole: 'test title', + email: 'email@example.com', + }, + ] + draft.rateInfos = [] + draft.contractType = 'BASE' as const + draft.contractExecutionStatus = 'EXECUTED' as const + draft.contractDateStart = new Date(Date.UTC(2025, 5, 1)) + draft.contractDateEnd = new Date(Date.UTC(2026, 4, 30)) + draft.contractDocuments = [ + { + name: 'contractDocument.pdf', + s3URL: 'fakeS3URL', + sha256: 'fakesha', + }, + ] + draft.managedCareEntities = ['MCO'] + draft.federalAuthorities = ['STATE_PLAN' as const] + draft.populationCovered = 'MEDICAID' as const + draft.contractAmendmentInfo = { + modifiedProvisions: { + inLieuServicesAndSettings: true, + modifiedRiskSharingStrategy: false, + modifiedIncentiveArrangements: false, + modifiedWitholdAgreements: false, + modifiedStateDirectedPayments: true, + modifiedPassThroughPayments: true, + modifiedPaymentsForMentalDiseaseInstitutions: true, + modifiedNonRiskPaymentArrangements: true, + }, + } + draft.statutoryRegulatoryAttestation = false + draft.statutoryRegulatoryAttestationDescription = 'No compliance' + + await updateTestHealthPlanFormData(server, draft) + const updatedContract = await fetchTestContract(server, draft.id) + return updatedContract +} + +export { + createTestContract, + submitTestContract, + createAndSubmitTestContract, + fetchTestContract, + createAndUpdateTestContractWithoutRates, + createAndUpdateTestContractWithRate, + createAndSubmitTestContractWithRate, +} diff --git a/services/app-api/src/testHelpers/gqlRateHelpers.ts b/services/app-api/src/testHelpers/gqlRateHelpers.ts index 41b6000288..d7f2b953d3 100644 --- a/services/app-api/src/testHelpers/gqlRateHelpers.ts +++ b/services/app-api/src/testHelpers/gqlRateHelpers.ts @@ -2,23 +2,25 @@ import SUBMIT_RATE from 'app-graphql/src/mutations/submitRate.graphql' import FETCH_RATE from 'app-graphql/src/queries/fetchRate.graphql' import UNLOCK_RATE from 'app-graphql/src/mutations/unlockRate.graphql' import UPDATE_DRAFT_CONTRACT_RATES from 'app-graphql/src/mutations/updateDraftContractRates.graphql' -import { findStatePrograms } from '../postgres' import { must } from './assertionHelpers' import { defaultFloridaRateProgram } from './gqlHelpers' -import { - mockDraftRate, - mockInsertRateArgs, - mockRateFormDataInput, -} from './rateDataMocks' +import { mockRateFormDataInput } from './rateDataMocks' import { sharedTestPrismaClient } from './storeHelpers' -import { insertDraftRate } from '../postgres/contractAndRates/insertRate' import { updateDraftRate } from '../postgres/contractAndRates/updateDraftRate' -import type { Contract, RateFormDataInput } from '../gen/gqlServer' +import type { + Contract, + RateFormData, + ActuaryContact, + ActuaryContactInput, + RateFormDataInput, + UpdateDraftContractRatesInput, + Rate, +} from '../gen/gqlServer' import type { RateType } from '../domain-models' -import type { InsertRateArgsType } from '../postgres/contractAndRates/insertRate' import type { ApolloServer } from 'apollo-server-lambda' import type { RateFormEditableType } from '../domain-models/contractAndRates' +import { createAndSubmitTestContractWithRate } from './gqlContractHelpers' const fetchTestRateById = async ( server: ApolloServer, @@ -43,12 +45,18 @@ const fetchTestRateById = async ( return result.data.fetchRate.rate } -const createAndSubmitTestRate = async ( - server: ApolloServer, - rateData?: InsertRateArgsType -): Promise => { - const rate = await createTestRate(rateData) - return await must(submitTestRate(server, rate.id, 'Initial submission')) +// rates must be initially submitted with a contract before they can be unlocked and submitted on their own. +async function createSubmitAndUnlockTestRate( + stateServer: ApolloServer, + cmsServer: ApolloServer +): Promise { + const contract = await createAndSubmitTestContractWithRate(stateServer) + const rateRevision = contract.packageSubmissions[0].rateRevisions[0] + const rateID = rateRevision.rateID + + const unlockedRate = await unlockTestRate(cmsServer, rateID, 'test unlock') + + return unlockedRate } const submitTestRate = async ( @@ -109,29 +117,204 @@ const unlockTestRate = async ( return updateResult.data.unlockRate.rate } -// USING PRISMA DIRECTLY BELOW --- we have no createRate or updateRate resolvers yet, but we have integration tests needing the workflows -const createTestRate = async ( - rateData?: Partial -): Promise => { - const prismaClient = await sharedTestPrismaClient() - const defaultRateData = { ...mockDraftRate() } - const initialData = { - ...defaultRateData, - ...rateData, // override with any new fields passed in +async function updateTestDraftRatesOnContract( + server: ApolloServer, + input: UpdateDraftContractRatesInput +): Promise { + const updateResult = await server.executeOperation({ + query: UPDATE_DRAFT_CONTRACT_RATES, + variables: { + input, + }, + }) + + if (updateResult.errors || !updateResult.data) { + throw new Error( + `updateDraftContractRates mutation failed with errors ${updateResult.errors}` + ) } - const programs = initialData.stateCode - ? [must(findStatePrograms(initialData.stateCode))[0]] - : [defaultFloridaRateProgram()] - const programIDs = programs.map((program) => program.id) + return updateResult.data.updateDraftContractRates.contract +} + +async function addNewRateToTestContract( + server: ApolloServer, + contract: Contract, + rateFormDataOverrides?: Partial +): Promise { + const rateUpdateInput = updateRatesInputFromDraftContract(contract) + + const addedInput = addNewRateToRateInput( + rateUpdateInput, + rateFormDataOverrides + ) - const draftRateData = mockInsertRateArgs({ - ...initialData, - rateProgramIDs: programIDs, - stateCode: 'FL', + return await updateTestDraftRatesOnContract(server, addedInput) +} + +function addNewRateToRateInput( + input: UpdateDraftContractRatesInput, + rateFormDataOverrides?: Partial +): UpdateDraftContractRatesInput { + const newFormData: RateFormDataInput = { + rateType: 'AMENDMENT', + rateCapitationType: 'RATE_CELL', + rateDateStart: '2024-01-01', + rateDateEnd: '2025-01-01', + rateDateCertified: '2024-01-02', + amendmentEffectiveDateStart: '2024-02-01', + amendmentEffectiveDateEnd: '2025-02-01', + rateProgramIDs: [defaultFloridaRateProgram().id], + + rateDocuments: [ + { + s3URL: 'foo://bar', + name: 'ratedoc1.doc', + sha256: 'foobar', + }, + ], + supportingDocuments: [ + { + s3URL: 'foo://bar1', + name: 'ratesupdoc1.doc', + sha256: 'foobar1', + }, + { + s3URL: 'foo://bar2', + name: 'ratesupdoc2.doc', + sha256: 'foobar2', + }, + ], + certifyingActuaryContacts: [ + { + name: 'Foo Person', + titleRole: 'Bar Job', + email: 'foo@example.com', + actuarialFirm: 'GUIDEHOUSE', + }, + ], + addtlActuaryContacts: [ + { + name: 'Bar Person', + titleRole: 'Baz Job', + email: 'bar@example.com', + actuarialFirm: 'OTHER', + actuarialFirmOther: 'Some Firm', + }, + ], + actuaryCommunicationPreference: 'OACT_TO_ACTUARY', + packagesWithSharedRateCerts: [], + + ...rateFormDataOverrides, + } + + return { + contractID: input.contractID, + updatedRates: [ + ...input.updatedRates, + { + type: 'CREATE' as const, + formData: newFormData, + }, + ], + } +} + +async function addLinkedRateToTestContract( + server: ApolloServer, + contract: Contract, + rateID: string +): Promise { + const rateUpdateInput = updateRatesInputFromDraftContract(contract) + + const addedInput = addLinkedRateToRateInput(rateUpdateInput, rateID) + + return await updateTestDraftRatesOnContract(server, addedInput) +} + +function addLinkedRateToRateInput( + input: UpdateDraftContractRatesInput, + rateID: string +): UpdateDraftContractRatesInput { + return { + contractID: input.contractID, + updatedRates: [ + ...input.updatedRates, + { + type: 'LINK' as const, + rateID, + }, + ], + } +} + +function formatGQLRateContractForSending( + contact: ActuaryContact +): ActuaryContactInput { + return { + ...contact, + id: contact.id || undefined, + actuarialFirmOther: contact.actuarialFirmOther || undefined, + } +} + +function formatRateDataForSending( + rateFormData: RateFormData +): RateFormDataInput { + return { + ...rateFormData, + certifyingActuaryContacts: rateFormData.certifyingActuaryContacts.map( + formatGQLRateContractForSending + ), + addtlActuaryContacts: rateFormData.addtlActuaryContacts + ? rateFormData.addtlActuaryContacts.map( + formatGQLRateContractForSending + ) + : undefined, + } +} + +function updateRatesInputFromDraftContract( + contract: Contract +): UpdateDraftContractRatesInput { + const draftRates = contract.draftRates + if (!draftRates) { + throw new Error('attempted to grab rate input from non-draft contract') + } + + const rateInputs = draftRates.map((rate) => { + if ( + rate.status === 'DRAFT' || + (rate.status === 'UNLOCKED' && + rate.parentContractID === contract.id) + ) { + // this is an editable child rate + const revision = rate.draftRevision + if (!revision) { + console.error( + 'programming error no draft revision found for rate', + rate + ) + throw new Error('No revision found for rate') + } + return { + type: 'UPDATE' as const, + rateID: rate.id, + formData: formatRateDataForSending(revision.formData), + } + } else { + // this is a linked rate. + return { + type: 'LINK' as const, + rateID: rate.id, + } + } }) - return must(await insertDraftRate(prismaClient, draftRateData)) + return { + contractID: contract.id, + updatedRates: rateInputs, + } } const createTestDraftRateOnContract = async ( @@ -220,10 +403,14 @@ const updateTestRate = async ( } export { - createTestRate, - createAndSubmitTestRate, createTestDraftRateOnContract, + createSubmitAndUnlockTestRate, updateTestDraftRateOnContract, + updateTestDraftRatesOnContract, + updateRatesInputFromDraftContract, + addNewRateToTestContract, + addLinkedRateToTestContract, + addLinkedRateToRateInput, fetchTestRateById, submitTestRate, unlockTestRate, diff --git a/services/app-api/src/testHelpers/index.ts b/services/app-api/src/testHelpers/index.ts index f49892e3f3..728fa7505d 100644 --- a/services/app-api/src/testHelpers/index.ts +++ b/services/app-api/src/testHelpers/index.ts @@ -23,8 +23,6 @@ export { getStateRecord } from './stateHelpers' export { consoleLogFullData } from './debugHelpers' export { - createTestRate, - createAndSubmitTestRate, fetchTestRateById, submitTestRate, unlockTestRate, diff --git a/services/app-api/src/testHelpers/rateDataMocks.ts b/services/app-api/src/testHelpers/rateDataMocks.ts index f816ec5aec..03f26d9d98 100644 --- a/services/app-api/src/testHelpers/rateDataMocks.ts +++ b/services/app-api/src/testHelpers/rateDataMocks.ts @@ -34,6 +34,7 @@ const mockDraftRate = ( updatedAt: new Date(), stateCode: 'MN', stateNumber: 111, + draftContracts: [], revisions: rate?.revisions ?? [ mockRateRevision( rate, @@ -66,6 +67,7 @@ const mockRateRevision = ( updatedAt: new Date(), updatedByID: 'someone', updatedReason: 'submit', + submittedContracts: [], updatedBy: { id: 'someone', createdAt: new Date(), @@ -81,6 +83,7 @@ const mockRateRevision = ( unlockInfo: null, submitInfoID: null, unlockInfoID: null, + relatedSubmissions: [], rateType: 'NEW', rateID: 'rateID', rateCertificationName: 'testState-123', diff --git a/services/app-graphql/codegen.yml b/services/app-graphql/codegen.yml index b388c3961a..bb0079ca2d 100644 --- a/services/app-graphql/codegen.yml +++ b/services/app-graphql/codegen.yml @@ -21,8 +21,10 @@ generates: CMSUser: ../domain-models#CMSUserType HealthPlanPackage: ../domain-models#HealthPlanPackageType Rate: ../domain-models#RateType - RateRevision: ../domain-models#RateRevisionType + RateRevision: ../domain-models#RateRevisionWithContractsType Contract: ../domain-models#ContractType as ContractDomainType + ContractRevision: ../domain-models#ContractRevisionType + ContractPackageSubmission: ../domain-models#ContractPackageSubmissionWithCauseType ../app-web/src/gen/gqlClient.tsx: documents: - src/queries/*.graphql diff --git a/services/app-graphql/src/mutations/submitContract.graphql b/services/app-graphql/src/mutations/submitContract.graphql new file mode 100644 index 0000000000..281272d878 --- /dev/null +++ b/services/app-graphql/src/mutations/submitContract.graphql @@ -0,0 +1,309 @@ +mutation submitContract($input: SubmitContractInput!) { + submitContract(input: $input) { + contract { + ...contractFields + + draftRevision { + ...contractRevisionFragment + } + + draftRates { + ...rateFields + + draftRevision { + ...rateRevisionFragmentForFetchContract + } + + revisions { + ...rateRevisionFragmentForFetchContract + } + } + + packageSubmissions { + ...packageSubmissionsFragment + } + } + } +} + + +fragment contractFields on Contract { + id + status + createdAt + updatedAt + initiallySubmittedAt + stateCode + state { + code + name + programs { + id + name + fullName + } + } + + stateNumber +} + +fragment rateFields on Rate { + id + createdAt + updatedAt + stateCode + stateNumber + parentContractID + state { + code + name + programs { + id + name + fullName + } + } + status + parentContractID + initiallySubmittedAt +} + +fragment rateRevisionFragmentForFetchContract on RateRevision { + id + rateID + createdAt + updatedAt + unlockInfo { + updatedAt + updatedBy + updatedReason + } + submitInfo { + updatedAt + updatedBy + updatedReason + } + formData { + rateType + rateCapitationType + rateDocuments { + name + s3URL + sha256 + dateAdded + } + supportingDocuments { + name + s3URL + sha256 + dateAdded + } + rateDateStart + rateDateEnd + rateDateCertified + amendmentEffectiveDateStart + amendmentEffectiveDateEnd + rateProgramIDs + rateCertificationName + certifyingActuaryContacts { + id + name + titleRole + email + actuarialFirm + actuarialFirmOther + } + addtlActuaryContacts { + id + name + titleRole + email + actuarialFirm + actuarialFirmOther + } + actuaryCommunicationPreference + packagesWithSharedRateCerts { + packageName + packageId + packageStatus + } + } + contractRevisions { + id + contract { + id + stateCode + stateNumber + } + createdAt + updatedAt + submitInfo { + updatedAt + updatedBy + updatedReason + } + unlockInfo { + updatedAt + updatedBy + updatedReason + } + formData { + programIDs + populationCovered + submissionType + riskBasedContract + submissionDescription + stateContacts { + name + titleRole + email + } + supportingDocuments { + name + s3URL + sha256 + dateAdded + } + contractType + contractExecutionStatus + contractDocuments { + name + s3URL + sha256 + dateAdded + } + contractDateStart + contractDateEnd + managedCareEntities + federalAuthorities + inLieuServicesAndSettings + modifiedBenefitsProvided + modifiedGeoAreaServed + modifiedMedicaidBeneficiaries + modifiedRiskSharingStrategy + modifiedIncentiveArrangements + modifiedWitholdAgreements + modifiedStateDirectedPayments + modifiedPassThroughPayments + modifiedPaymentsForMentalDiseaseInstitutions + modifiedMedicalLossRatioStandards + modifiedOtherFinancialPaymentIncentive + modifiedEnrollmentProcess + modifiedGrevienceAndAppeal + modifiedNetworkAdequacyStandards + modifiedLengthOfContract + modifiedNonRiskPaymentArrangements + } + } +} + +fragment contractFormDataFragment on ContractFormData { + programIDs + + populationCovered + submissionType + + riskBasedContract + submissionDescription + + stateContacts { + name + titleRole + email + } + + supportingDocuments { + name + s3URL + sha256 + dateAdded + } + + contractType + contractExecutionStatus + contractDocuments { + name + s3URL + sha256 + dateAdded + } + + contractDateStart + contractDateEnd + managedCareEntities + federalAuthorities + inLieuServicesAndSettings + modifiedBenefitsProvided + modifiedGeoAreaServed + modifiedMedicaidBeneficiaries + modifiedRiskSharingStrategy + modifiedIncentiveArrangements + modifiedWitholdAgreements + modifiedStateDirectedPayments + modifiedPassThroughPayments + modifiedPaymentsForMentalDiseaseInstitutions + modifiedMedicaidBeneficiaries + modifiedMedicalLossRatioStandards + modifiedOtherFinancialPaymentIncentive + modifiedEnrollmentProcess + modifiedGrevienceAndAppeal + modifiedNetworkAdequacyStandards + modifiedLengthOfContract + modifiedNonRiskPaymentArrangements +} + +fragment contractRevisionFragment on ContractRevision { + id + createdAt + updatedAt + contractName + + submitInfo { + updatedAt + updatedBy + updatedReason + } + + unlockInfo { + updatedAt + updatedBy + updatedReason + } + + formData { + ...contractFormDataFragment + } +} + +fragment packageSubmissionsFragment on ContractPackageSubmission { + cause + submitInfo { + ...updateInformationFields + } + + submittedRevisions { + ...submittableRevisionsFields + } + + contractRevision { + ...contractRevisionFragment + } + rateRevisions { + ...rateRevisionFragmentForFetchContract + } +} + +fragment submittableRevisionsFields on SubmittableRevision { + ... on ContractRevision { + ...contractRevisionFragment + } + ... on RateRevision { + ...rateRevisionFragmentForFetchContract + } +} + +fragment updateInformationFields on UpdateInformation { + updatedAt + updatedBy + updatedReason +} diff --git a/services/app-graphql/src/mutations/updateDraftContractRates.graphql b/services/app-graphql/src/mutations/updateDraftContractRates.graphql index 620df39190..964be67bf1 100644 --- a/services/app-graphql/src/mutations/updateDraftContractRates.graphql +++ b/services/app-graphql/src/mutations/updateDraftContractRates.graphql @@ -63,6 +63,7 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { id createdAt updatedAt + parentContractID status draftRevision { @@ -80,6 +81,7 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { rateCapitationType rateDateStart rateDateEnd + rateCertificationName rateDateCertified amendmentEffectiveDateStart amendmentEffectiveDateEnd @@ -90,11 +92,15 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { name email titleRole + actuarialFirm + actuarialFirmOther } addtlActuaryContacts { name email titleRole + actuarialFirm + actuarialFirmOther } rateDocuments { name @@ -108,6 +114,11 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { sha256 dateAdded } + packagesWithSharedRateCerts { + packageName + packageId + packageStatus + } } } @@ -136,11 +147,13 @@ mutation updateDraftContractRates($input: UpdateDraftContractRatesInput!) { name email titleRole + actuarialFirm } addtlActuaryContacts { name email titleRole + actuarialFirm } rateDocuments { name diff --git a/services/app-graphql/src/queries/fetchContract.graphql b/services/app-graphql/src/queries/fetchContract.graphql index 6bd0ff549d..788ee46f82 100644 --- a/services/app-graphql/src/queries/fetchContract.graphql +++ b/services/app-graphql/src/queries/fetchContract.graphql @@ -52,6 +52,7 @@ fragment rateFields on Rate { updatedAt stateCode stateNumber + parentContractID state { code name @@ -62,11 +63,13 @@ fragment rateFields on Rate { } } status + parentContractID initiallySubmittedAt } fragment rateRevisionFragmentForFetchContract on RateRevision { id + rateID createdAt updatedAt unlockInfo { diff --git a/services/app-graphql/src/queries/fetchRate.graphql b/services/app-graphql/src/queries/fetchRate.graphql index 240d283f19..f5dc643996 100644 --- a/services/app-graphql/src/queries/fetchRate.graphql +++ b/services/app-graphql/src/queries/fetchRate.graphql @@ -1,5 +1,6 @@ fragment rateRevisionFragmentForFetchRate on RateRevision { id + rateID createdAt updatedAt unlockInfo { @@ -132,6 +133,7 @@ fragment rateFields on Rate { updatedAt stateCode stateNumber + parentContractID state { code name @@ -142,6 +144,7 @@ fragment rateFields on Rate { } } status + parentContractID initiallySubmittedAt } diff --git a/services/app-graphql/src/queries/indexRates.graphql b/services/app-graphql/src/queries/indexRates.graphql index f70cbb8d00..653c3de58b 100644 --- a/services/app-graphql/src/queries/indexRates.graphql +++ b/services/app-graphql/src/queries/indexRates.graphql @@ -19,9 +19,11 @@ query indexRates { } status initiallySubmittedAt + parentContractID draftRevision { id + rateID createdAt updatedAt unlockInfo { @@ -151,6 +153,7 @@ query indexRates { revisions { id + rateID createdAt updatedAt unlockInfo { diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index 7658a27bf2..fb3e2d29ab 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -348,6 +348,37 @@ type Mutation { - Attempted to unlock a rate in the DRAFT or UNLOCKED state """ submitRate(input: SubmitRateInput!): SubmitRatePayload! + + """ + submitContract submits the given package for review by CMS. + + This can only be called by a StateUser from the state the package is for. + The package must be either in DRAFT or UNLOCKED state to be submitted + On resubmit the `submittedReason` field must be filled out. + The submission must be complete for this mutation to succeed. All required fields + in the ContractFormData and RateFormData must be filled out correctly. + Email notifications will be sent to all the relevant parties + + Errors: + - ForbiddenError: + - A CMSUser called this + - A state user from a different state called this. + - UserInputError + - A package cannot be found with the given `pkgID` + - The contract and or rates do not have all required fields filled out + - INVALID_PACKAGE_STATUS + - Attempted to submit a package in the SUBMITTED or RESUBMITTED state + - INTERNAL_SERVER_ERROR + - DB_ERROR + - Postgres returns error when attempting to find a package + - Postgres returns error when attempting to update a package + - Attempt to find state programs from json file returns an error + - EMAIL_ERROR + - Sending state or CMS email failed. + """ + submitContract( + input: SubmitContractInput! + ): SubmitContractPayload! } input CreateHealthPlanPackageInput { @@ -491,6 +522,16 @@ type SubmitHealthPlanPackagePayload { pkg: HealthPlanPackage! } +input SubmitContractInput { + contractID: ID! + "User given reason this package was re-submitted. Left blank on initial submit." + submittedReason: String +} + +type SubmitContractPayload { + contract: Contract! +} + input UnlockHealthPlanPackageInput { pkgID: ID! "User given reason this package was unlocked" @@ -1241,6 +1282,8 @@ for the rate and contains the full data from when the rate cert was submitted """ type RateRevision { id: ID! + rate: Rate + rateID: String! createdAt: DateTime! updatedAt: DateTime! """ @@ -1281,6 +1324,8 @@ type Rate { This value is used to generate the rateName """ stateNumber: Int! + "parentContractID is the ID of the contract that initially submitted this rate" + parentContractID: ID! "The currently modifiable revision if the rate is DRAFT or UNLOCKED" draftRevision: RateRevision """ @@ -1382,7 +1427,8 @@ type Contract { """ packageSubmissions are a snapshot of the contract and its related rates through time each packageSubmission was created by a submission of this contract and/or its related rates - a DRAFT Contract will have no packageSubmissions + a DRAFT Contract will have no packageSubmissions. Returned in _ascending_ order. Most recent + submission is in the first position in the array. """ packageSubmissions: [ContractPackageSubmission!]! } diff --git a/services/app-web/src/common-code/testHelpers.ts b/services/app-web/src/common-code/testHelpers.ts new file mode 100644 index 0000000000..6b99763bf5 --- /dev/null +++ b/services/app-web/src/common-code/testHelpers.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function jsonStringify(obj: any): string { + return JSON.stringify(obj, null, ' ') +} + +export { + jsonStringify +} diff --git a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/SingleRateSummarySection.test.tsx b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/SingleRateSummarySection.test.tsx index dc541a00bb..4d1a58a418 100644 --- a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/SingleRateSummarySection.test.tsx +++ b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/SingleRateSummarySection.test.tsx @@ -448,7 +448,7 @@ describe('SingleRateSummarySection', () => { // no unlock rate button present expect( - await screen.queryByRole('button', { + screen.queryByRole('button', { name: 'Unlock rate', }) ).toBeNull() diff --git a/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/RateDetailsSummarySectionV2.test.tsx b/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/RateDetailsSummarySectionV2.test.tsx index c24cda9736..37cbee1f3b 100644 --- a/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/RateDetailsSummarySectionV2.test.tsx +++ b/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/RateDetailsSummarySectionV2.test.tsx @@ -25,9 +25,11 @@ describe('RateDetailsSummarySection', () => { state: mockMNState(), stateCode: 'MN', stateNumber: 5, + parentContractID: 'fake-id', revisions: [], draftRevision: { id: '1234', + rateID: '5678', createdAt: new Date('01/01/2021'), updatedAt: new Date('01/01/2021'), contractRevisions: [], @@ -72,9 +74,11 @@ describe('RateDetailsSummarySection', () => { state: mockMNState(), stateCode: 'MN', stateNumber: 5, + parentContractID: 'fake-id', revisions: [], draftRevision: { id: '1234', + rateID: '5678', createdAt: new Date('01/01/2021'), updatedAt: new Date('01/01/2021'), contractRevisions: [], diff --git a/services/app-web/src/testHelpers/apolloMocks/contractPackageDataMock.ts b/services/app-web/src/testHelpers/apolloMocks/contractPackageDataMock.ts index 4cbbfa9a24..8d4f989464 100644 --- a/services/app-web/src/testHelpers/apolloMocks/contractPackageDataMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/contractPackageDataMock.ts @@ -36,8 +36,10 @@ function mockContractPackageDraft( revisions: [], state: mockMNState(), stateNumber: 5, + parentContractID: 'foo-baz', draftRevision: { id: '123', + rateID: '456', contractRevisions: [], createdAt: new Date(), updatedAt: new Date(), @@ -455,6 +457,7 @@ function mockContractPackageUnlocked( rateRevisions: [ { id: '1234', + rateID: '456', createdAt: new Date('01/01/2023'), updatedAt: new Date('01/01/2023'), contractRevisions: [], diff --git a/services/app-web/src/testHelpers/apolloMocks/rateDataMock.ts b/services/app-web/src/testHelpers/apolloMocks/rateDataMock.ts index d3e050034a..26a2acca1c 100644 --- a/services/app-web/src/testHelpers/apolloMocks/rateDataMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/rateDataMock.ts @@ -80,6 +80,7 @@ const contractRevisionOnRateDataMock = ( const rateRevisionDataMock = (data?: Partial): RateRevision => { return { id: data?.id ?? uuidv4(), + rateID: '456', createdAt: '2023-10-16T19:01:21.389Z', updatedAt: '2023-10-16T19:02:26.767Z', unlockInfo: null, @@ -174,6 +175,7 @@ const draftRateDataMock = ( updatedAt: '2023-10-16T19:01:21.389Z', stateCode: 'MN', stateNumber: 10, + parentContractID: 'foo-bar', state: mockMNState(), status: 'DRAFT', initiallySubmittedAt: '2023-10-16', @@ -202,6 +204,7 @@ const rateDataMock = ( status: 'RESUBMITTED', initiallySubmittedAt: '2023-10-16', draftRevision: null, + parentContractID: 'foo-bar', ...rate, id: rateID, revisions: [