From 4f1fafc5f5969db3f4cbbbf3451eaa880d06e406 Mon Sep 17 00:00:00 2001 From: pearl-truss <67110378+pearl-truss@users.noreply.github.com> Date: Thu, 8 Feb 2024 16:01:08 -0500 Subject: [PATCH 1/8] MCR-3839 restrict 3rd party API access by IP address (#2195) * user the principalID passed back from authorizer to fetch user * simplify localAuthn to not contain 3rd party authorizer logic * resolve issue with deployed environment not having an authprovider for 3rd party requests * make sure normal userFetcher isn't called when request comes from authroizer * testing commit to check if the path exist on the event in the same way in deployed * additional output in thrown error for testing * update logic for checking if request is coming from 3rd party * pr fixes for better error message and remove duplicate code * wip: deploy serverless changes that donot deploy locally * remove changes to infra api serverless * use environmental variables for restricting access by ip address * start to move ip checkout out of generatePolicy * temp pass authroizer * remove IP address logic in authroizer * re-implement restriction by ip address in authorizer lambda * add temp logs for debugging in dev * temp remove otel for debugging * add additional logs for logged reading host error * Add otel logging back * additional temp logging * add error handling for if host is undefined * move json parse into try/catch * temp hardcode host to debug * add additional logs * parse host for event headers * attempt to read only first header * try the multValue header instead * move ip address check to thirdPartyAuthn function * fix typo, better error message * better error message * fix ip address to pull from event and not context * temp log allowed ip address * remove temp logging of allowed ip addresses * fix default allowed ip address * updated allowed ip addresses to be in json array format * remove need for json array in envr * rerun jobs * code formatting cleanup * cypress re-run * update cypress config to read env for allow ip addresses * update cypress config * remove changes to cypress config * add condition to authorizer to not use in pr environments * Add condition to resource * move condition to right spot for syntax * update code and cypress test so that failure is expected against deployed environments * add ip check to authorizer * move ip address check to apollo middleware, other pr fixes * remove undefined from authn * update so that ip address check uses includes --- .envrc | 1 + docs/Configuration.md | 6 +++ services/app-api/serverless.yml | 6 ++- services/app-api/src/handlers/apollo_gql.ts | 49 ++++++++++++++++--- .../handlers/third_party_API_authorizer.ts | 4 +- services/cypress/cypress.config.ts | 1 - .../thirdPartyAPIAccess.spec.ts | 2 +- 7 files changed, 56 insertions(+), 13 deletions(-) diff --git a/.envrc b/.envrc index 3fa8c01ecb..2a0f3fe8f6 100644 --- a/.envrc +++ b/.envrc @@ -26,6 +26,7 @@ export DATABASE_URL='postgresql://postgres:shhhsecret@localhost:5432/postgres?sc export EMAILER_MODE='LOCAL' export LD_SDK_KEY='this-value-must-be-set-in-local' export PARAMETER_STORE_MODE='LOCAL' +export ALLOWED_IP_ADDRESSES='127.0.0.1' export JWT_SECRET='3fd2e448ed2cec1fa46520f1b64bcb243c784f68db41ea67ef9abc45c12951d3e770162829103c439f01d2b860d06ed0da1a08895117b1ef338f1e4ed176448a' # pragma: allowlist secret export REACT_APP_OTEL_COLLECTOR_URL='http://localhost:4318/v1/traces' diff --git a/docs/Configuration.md b/docs/Configuration.md index 0136eaf640..b8e7b92f5a 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -52,6 +52,12 @@ valid values: A URL where a running app-web can be reached It's used as the redirects for login by app-web when it configures login via IDM. +### `ALLOWED_IP_ADDRESSES` + +Read by `app-api` and Cypress + +Third party access to the MC-Review API is restricted by IP address. It must be set to a string that contains a comma separated list of IP address OR it can be set to `ALLOW_ALL` for the dev environment and for testing purposes. + ### `APP_VERSION` Read by `app-api` diff --git a/services/app-api/serverless.yml b/services/app-api/serverless.yml index 7b419b7bcc..47f904228a 100644 --- a/services/app-api/serverless.yml +++ b/services/app-api/serverless.yml @@ -34,6 +34,7 @@ custom: reactAppOtelCollectorUrl: ${env:REACT_APP_OTEL_COLLECTOR_URL, ssm:/configuration/react_app_otel_collector_url} dbURL: ${env:DATABASE_URL} ldSDKKey: ${env:LD_SDK_KEY, ssm:/configuration/ld_sdk_key_feds} + allowedIpAddresses: ${env:ALLOWED_IP_ADDRESSES, ssm:/configuration/allowed_ip_addresses} # because the secret is in JSON in secret manager, we have to pass it into jwtSecret when not running locally jwtSecretJSON: ${env:CF_CONFIG_IGNORED_LOCALLY, ssm:/aws/reference/secretsmanager/api_jwt_secret_${sls:stage}} jwtSecret: ${env:JWT_SECRET, self:custom.jwtSecretJSON.jwtsigningkey} @@ -157,6 +158,7 @@ provider: AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-handler OPENTELEMETRY_COLLECTOR_CONFIG_FILE: /var/task/collector.yml LD_SDK_KEY: ${self:custom.ldSDKKey} + ALLOWED_IP_ADDRESSES: ${self:custom.allowedIpAddresses} JWT_SECRET: ${self:custom.jwtSecret} layers: @@ -179,7 +181,7 @@ functions: third_party_api_authorizer: handler: src/handlers/third_party_API_authorizer.main - + otel: handler: src/handlers/otel_proxy.main events: @@ -187,7 +189,7 @@ functions: path: otel method: post cors: true - + graphql: handler: src/handlers/apollo_gql.graphqlHandler events: diff --git a/services/app-api/src/handlers/apollo_gql.ts b/services/app-api/src/handlers/apollo_gql.ts index 0e675f6f42..6b64e9bb58 100644 --- a/services/app-api/src/handlers/apollo_gql.ts +++ b/services/app-api/src/handlers/apollo_gql.ts @@ -72,9 +72,6 @@ function contextForRequestForFetcher(userFetcher: userFromAuthProvider): ({ if (authProvider || fromThirdPartyAuthorizer) { try { - // check if the user is stored in postgres - // going to clean this up, but we need the store in the - // userFetcher to query postgres. This code is a duped. const dbURL = process.env.DATABASE_URL ?? '' const secretsManagerSecret = process.env.SECRETS_MANAGER_SECRET ?? '' @@ -116,7 +113,9 @@ function contextForRequestForFetcher(userFetcher: userFromAuthProvider): ({ } } catch (err) { console.error('Error attempting to fetch user: ', err) - throw new Error('Log: placing user in gql context failed') + throw new Error( + `Log: placing user in gql context failed, ${err}` + ) } } else { throw new Error('Log: no AuthProvider from an internal API user.') @@ -148,6 +147,38 @@ function localAuthMiddleware(wrapped: APIGatewayProxyHandler): Handler { } } +function ipRestrictionMiddleware( + allowedIps: string +): (wrappedArg: Handler) => Handler { + return function (wrapped: Handler): Handler { + return async function (event, context, completion) { + const ipAddress = event.requestContext.identity.sourceIp + const fromThirdPartyAuthorizer = event.requestContext.path.includes( + '/v1/graphql/external' + ) + + if (fromThirdPartyAuthorizer) { + const isValidIpAddress = + allowedIps.includes(ipAddress) || + allowedIps.includes('ALLOW_ALL') + + if (!isValidIpAddress) { + return Promise.resolve({ + statusCode: 403, + body: `{ "error": IP Address ${ipAddress} is not in the allowed list }\n`, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Credentials': true, + }, + }) + } + } + + return await wrapped(event, context, completion) + } + } +} + // Tracing Middleware function tracingMiddleware(wrapped: Handler): Handler { return async function (event, context, completion) { @@ -197,6 +228,7 @@ async function initializeGQLHandler(): Promise { const otelCollectorUrl = process.env.REACT_APP_OTEL_COLLECTOR_URL const parameterStoreMode = process.env.PARAMETER_STORE_MODE const ldSDKKey = process.env.LD_SDK_KEY + const allowedIpAddresses = process.env.ALLOWED_IP_ADDRESSES const jwtSecret = process.env.JWT_SECRET // START Assert configuration is valid @@ -212,6 +244,9 @@ async function initializeGQLHandler(): Promise { if (stageName === undefined) throw new Error('Configuration Error: stage is required') + if (allowedIpAddresses === undefined) + throw new Error('Configuration Error: allowed IP addresses is required') + if (!dbURL) { throw new Error('Init Error: DATABASE_URL is required to run app-api') } @@ -419,11 +454,13 @@ async function initializeGQLHandler(): Promise { // init tracer and set the middleware. tracer needs to be global. tracer = createTracer('app-api-' + stageName) - const tracingHandler = tracingMiddleware(handler) + const combinedHandler = ipRestrictionMiddleware(allowedIpAddresses)( + tracingMiddleware(handler) + ) // Locally, we wrap our handler in a middleware that returns 403 for unauthenticated requests const isLocal = authMode === 'LOCAL' - return isLocal ? localAuthMiddleware(tracingHandler) : tracingHandler + return isLocal ? localAuthMiddleware(combinedHandler) : combinedHandler } const handlerPromise = initializeGQLHandler() diff --git a/services/app-api/src/handlers/third_party_API_authorizer.ts b/services/app-api/src/handlers/third_party_API_authorizer.ts index 61b0e58083..7e14775c4b 100644 --- a/services/app-api/src/handlers/third_party_API_authorizer.ts +++ b/services/app-api/src/handlers/third_party_API_authorizer.ts @@ -32,10 +32,8 @@ export const main: APIGatewayTokenAuthorizerHandler = async ( try { // authentication step for validating JWT token const userId = jwtLib.userIDFromToken(authToken) - if (userId instanceof Error) { - const msg = 'Invalid auth token' - console.error(msg) + console.error('Invalid auth token') return generatePolicy(undefined, event) } diff --git a/services/cypress/cypress.config.ts b/services/cypress/cypress.config.ts index 780d86179b..1401d8aed1 100644 --- a/services/cypress/cypress.config.ts +++ b/services/cypress/cypress.config.ts @@ -36,7 +36,6 @@ module.exports = defineConfig({ newConfig.env.COGNITO_REGION = process.env.COGNITO_REGION newConfig.env.COGNITO_IDENTITY_POOL_ID = process.env.COGNITO_IDENTITY_POOL_ID newConfig.env.COGNITO_USER_POOL_WEB_CLIENT_ID = process.env.COGNITO_USER_POOL_WEB_CLIENT_ID - on('before:browser:launch', (browser, launchOptions) => { prepareAudit(launchOptions) }) diff --git a/services/cypress/integration/thirdPartyAPIAccess/thirdPartyAPIAccess.spec.ts b/services/cypress/integration/thirdPartyAPIAccess/thirdPartyAPIAccess.spec.ts index f6befbecee..a4d81f04c4 100644 --- a/services/cypress/integration/thirdPartyAPIAccess/thirdPartyAPIAccess.spec.ts +++ b/services/cypress/integration/thirdPartyAPIAccess/thirdPartyAPIAccess.spec.ts @@ -69,7 +69,7 @@ describe('thirdPartyAPIAccess', () => { body: '{"query":"query IndexRates { indexRates { totalCount edges { node { id } } } }"}', failOnStatusCode: false, }).then(res => { - expect(res.status).to.equal(200) + expect(res.status).to.equal(200) // okay }) }) From 7c19422df01bf81a5840808ec085bdf328add194 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Feb 2024 15:34:43 -0500 Subject: [PATCH 2/8] Bump @aws-sdk/s3-request-presigner from 3.504.0 to 3.509.0 (#2246) Bumps [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) from 3.504.0 to 3.509.0. - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.509.0/packages/s3-request-presigner) --- updated-dependencies: - dependency-name: "@aws-sdk/s3-request-presigner" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index ba24a20abe..6d43e28d4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3845,9 +3845,9 @@ tslib "^2.5.0" "@aws-sdk/s3-request-presigner@^3.485.0": - version "3.504.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.504.0.tgz#4288f6381c1a63fae417754eae4f0652cf0a51b5" - integrity sha512-5FxVdRufiFLSUDJ/Qul5JFPHjhFFzo+C6u53bzbi7gaSshA6lLLhJ9KbVk2LmKE1mTR+nh2+JebI6y+3njtkzw== + version "3.509.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.509.0.tgz#53f3f90fe52500bafbf7504ca323ea8b6493af01" + integrity sha512-6EwvUtoObMZ2s7cx3zDJBgcfvqXQ8ABoDNXcSm3Y8/hdhJq8ovICwanTSWkx6ylFw+TmPt0Qo57U4SeCQd0qYA== dependencies: "@aws-sdk/signature-v4-multi-region" "3.502.0" "@aws-sdk/types" "3.502.0" @@ -12423,9 +12423,9 @@ integrity sha512-Abq9fBviLV93OiXMu+f6r0elxCzRwc0RC5f99cU892uBITL44pTvgvEqlRlPRi8EGcO1z7Cp8A4d0s/p3J/+Nw== "@types/node@^16.18.39": - version "16.18.79" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.79.tgz#153e25561b271cf87dc1b28d38f98cebd514d788" - integrity sha512-Qd7jdLR5zmnIyMhfDrfPqN5tUCvreVpP3Qrf2oSM+F7SNzlb/MwHISGUkdFHtevfkPJ3iAGyeQI/jsbh9EStgQ== + version "16.18.80" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.80.tgz#9644e2d8acaf8163d46d23e05ce3822e9379dfc3" + integrity sha512-vFxJ1Iyl7A0+xB0uW1r1v504yItKZLdqg/VZELUZ4H02U0bXAgBisSQ8Erf0DMruNFz9ggoiEv6T8Ll9bTg8Jw== "@types/normalize-package-data@^2.4.0": version "2.4.1" From e7f099978857a5138e2888999f343e2d36e5383e Mon Sep 17 00:00:00 2001 From: haworku Date: Mon, 12 Feb 2024 11:31:56 -0800 Subject: [PATCH 3/8] MCR-3772 MCR-3771 Edit and submit standalone rate (#2238) --- .gitignore | 1 + .../src/domain-models/nullstoUndefined.ts | 37 ++ .../rate/generateRateCertificationName.ts | 56 ++ .../app-api/src/resolvers/rate/submitRate.ts | 43 +- .../app-api/src/testHelpers/gqlHelpers.ts | 2 + .../src/mutations/submitRate.graphql | 2 + .../src/mutations/unlockRate.graphql | 2 + .../app-graphql/src/queries/fetchRate.graphql | 2 + services/app-graphql/src/schema.graphql | 18 +- .../app-proto/src/health_plan_form_data.proto | 1 + .../healthPlanFormDataProto/zodSchemas.ts | 1 + .../app-web/src/formHelpers/formatters.ts | 107 +++- services/app-web/src/pages/App/AppRoutes.tsx | 22 +- .../src/pages/RateEdit/RateEdit.test.tsx | 5 +- .../app-web/src/pages/RateEdit/RateEdit.tsx | 85 ++- .../RateSummary/RateSummary.test.tsx | 18 +- .../RateSummary/RateSummary.tsx | 28 +- .../RateSummary/index.ts | 0 .../pages/StateDashboard/StateDashboard.tsx | 15 +- .../Contacts/ActuaryContactFields.tsx | 5 +- ...missionContainer.tsx => FormContainer.tsx} | 12 +- .../New/NewStateSubmissionForm.tsx | 6 +- .../PackagesWithSharedRates.tsx | 27 +- .../RateDetails/RateDetails.tsx | 5 +- .../RateDetails/RateDetailsSchema.ts | 28 +- .../{SingleRateCert => }/SingleRateCert.tsx | 20 +- .../RateDetails/V2/RateDetailsV2.test.tsx | 154 +++++ .../RateDetails/V2/RateDetailsV2.tsx | 377 +++++++++++++ .../RateDetails/V2/SingleRateCertV2.tsx | 526 ++++++++++++++++++ .../StateSubmission/StateSubmissionForm.tsx | 8 +- .../SubmissionSideNav/SubmissionSideNav.tsx | 1 + .../src/pages/SubmissionSummary/index.ts | 2 +- .../testHelpers/apolloMocks/rateDataMock.ts | 2 + 33 files changed, 1478 insertions(+), 140 deletions(-) create mode 100644 services/app-api/src/domain-models/nullstoUndefined.ts create mode 100644 services/app-api/src/resolvers/rate/generateRateCertificationName.ts rename services/app-web/src/pages/{SubmissionSummary => }/RateSummary/RateSummary.test.tsx (93%) rename services/app-web/src/pages/{SubmissionSummary => }/RateSummary/RateSummary.tsx (79%) rename services/app-web/src/pages/{SubmissionSummary => }/RateSummary/index.ts (100%) rename services/app-web/src/pages/StateSubmission/{StateSubmissionContainer.tsx => FormContainer.tsx} (50%) rename services/app-web/src/pages/StateSubmission/RateDetails/{PackagesWithSharedRates => }/PackagesWithSharedRates.tsx (91%) rename services/app-web/src/pages/StateSubmission/RateDetails/{SingleRateCert => }/SingleRateCert.tsx (98%) create mode 100644 services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.test.tsx create mode 100644 services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.tsx create mode 100644 services/app-web/src/pages/StateSubmission/RateDetails/V2/SingleRateCertV2.tsx diff --git a/.gitignore b/.gitignore index c47371488e..b118b25871 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ tsconfig.tsbuildinfo .serverless .eslintcache /.env +.nx tests_output *.log coverage/ diff --git a/services/app-api/src/domain-models/nullstoUndefined.ts b/services/app-api/src/domain-models/nullstoUndefined.ts new file mode 100644 index 0000000000..f2f6ed6ebc --- /dev/null +++ b/services/app-api/src/domain-models/nullstoUndefined.ts @@ -0,0 +1,37 @@ +/* + Recursively replaces all nulls with undefineds + GQL return types are T | null instead of T | undefined which match our zod .optional() domain types + This functions allows us convert GQL to zod-friendly types to type match zod and apollo server types + and avoid manual type casting or null coalescing work + + Adapted from https://github.com/apollographql/apollo-client/issues/2412 +*/ + +type RecursivelyReplaceNullWithUndefined = T extends null + ? undefined + : T extends Date + ? T + : { + [K in keyof T]: T[K] extends (infer U)[] + ? RecursivelyReplaceNullWithUndefined[] + : RecursivelyReplaceNullWithUndefined + } + +export function nullsToUndefined( + obj: T +): RecursivelyReplaceNullWithUndefined { + if (obj === null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return undefined as any + } + + // object check based on: https://stackoverflow.com/a/51458052/6489012 + if (obj?.constructor.name === 'Object') { + for (const key in obj) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + obj[key] = nullsToUndefined(obj[key]) as any + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return obj as any +} diff --git a/services/app-api/src/resolvers/rate/generateRateCertificationName.ts b/services/app-api/src/resolvers/rate/generateRateCertificationName.ts new file mode 100644 index 0000000000..732dc31ce0 --- /dev/null +++ b/services/app-api/src/resolvers/rate/generateRateCertificationName.ts @@ -0,0 +1,56 @@ +import { formatRateNameDate } from '../../../../app-web/src/common-code/dateHelpers' +import { packageName } from '../../../../app-web/src/common-code/healthPlanFormDataType' +import type { ProgramArgType } from '../../../../app-web/src/common-code/healthPlanFormDataType' +import type { RateFormDataType } from '../../domain-models/contractAndRates' + +const generateRateCertificationName = ( + rateFormData: RateFormDataType, + stateCode: string, + stateNumber: number, + statePrograms: ProgramArgType[] +): string => { + const { + rateType, + rateProgramIDs, + amendmentEffectiveDateEnd, + amendmentEffectiveDateStart, + rateDateCertified, + rateDateEnd, + rateDateStart, + } = rateFormData + + let rateName = `${packageName( + stateCode, + stateNumber, + rateProgramIDs ?? [], + statePrograms + )}-RATE` + if (rateType === 'NEW' && rateDateStart) { + rateName = rateName.concat( + '-', + formatRateNameDate(rateDateStart), + '-', + formatRateNameDate(rateDateEnd), + '-', + 'CERTIFICATION' + ) + } + + if (rateType === 'AMENDMENT') { + rateName = rateName.concat( + '-', + formatRateNameDate(amendmentEffectiveDateStart), + '-', + formatRateNameDate(amendmentEffectiveDateEnd), + '-', + 'AMENDMENT' + ) + } + + if (rateDateCertified) { + rateName = rateName.concat('-', formatRateNameDate(rateDateCertified)) + } + return rateName +} + +export { generateRateCertificationName } diff --git a/services/app-api/src/resolvers/rate/submitRate.ts b/services/app-api/src/resolvers/rate/submitRate.ts index e677d26ec4..81791d3dee 100644 --- a/services/app-api/src/resolvers/rate/submitRate.ts +++ b/services/app-api/src/resolvers/rate/submitRate.ts @@ -4,14 +4,20 @@ import { setErrorAttributesOnActiveSpan, setResolverDetailsOnActiveSpan, } from '../attributeHelper' -import type { RateFormDataType } from '../../domain-models' import { isStateUser } from '../../domain-models' import { logError } from '../../logger' import { ForbiddenError, UserInputError } from 'apollo-server-lambda' import { NotFoundError } from '../../postgres' import { GraphQLError } from 'graphql/index' import type { LDService } from '../../launchDarkly/launchDarkly' +import { generateRateCertificationName } from './generateRateCertificationName' +import { findStatePrograms } from '../../../../app-web/src/common-code/healthPlanFormDataType/findStatePrograms' +import { nullsToUndefined } from '../../domain-models/nullstoUndefined' +/* + Submit rate will change a draft revision to submitted and generate a rate name if one is missing + Also, if form data is passed in (such as on standalone rate edits) the form data itself will be updated +*/ export function submitRate( store: Store, launchDarkly: LDService @@ -86,15 +92,33 @@ export function submitRate( }) } + // prepare to generate rate cert name - either use new form data coming down on submit or unsubmitted submission data already in database + const stateCode = unsubmittedRate.stateCode + const stateNumber = unsubmittedRate.stateNumber + const statePrograms = findStatePrograms(stateCode) + const generatedRateCertName = formData + ? generateRateCertificationName( + nullsToUndefined(formData), + stateCode, + stateNumber, + statePrograms + ) + : generateRateCertificationName( + draftRateRevision.formData, + stateCode, + stateNumber, + statePrograms + ) + + // combine existing db draft data with any form data added on submit // call submit rate handler const submittedRate = await store.submitRate({ rateID, submittedByUserID: user.id, - submitReason, + submitReason: submitReason ?? 'Initial submission', formData: formData ? { - rateType: (formData.rateType ?? - undefined) as RateFormDataType['rateType'], + rateType: formData.rateType ?? undefined, rateCapitationType: formData.rateCapitationType ?? undefined, rateDocuments: formData.rateDocuments ?? [], @@ -108,8 +132,6 @@ export function submitRate( amendmentEffectiveDateEnd: formData.amendmentEffectiveDateEnd ?? undefined, rateProgramIDs: formData.rateProgramIDs ?? [], - rateCertificationName: - formData.rateCertificationName ?? undefined, certifyingActuaryContacts: formData.certifyingActuaryContacts ? formData.certifyingActuaryContacts.map( @@ -140,7 +162,14 @@ export function submitRate( actuaryCommunicationPreference: formData.actuaryCommunicationPreference ?? undefined, packagesWithSharedRateCerts: - formData.packagesWithSharedRateCerts ?? [], + formData.packagesWithSharedRateCerts.map((pkg) => ({ + packageName: pkg.packageName ?? undefined, + packageId: pkg.packageId ?? undefined, + packageStatus: pkg.packageStatus ?? undefined, + })), + rateCertificationName: + formData.rateCertificationName ?? + generatedRateCertName, } : undefined, }) diff --git a/services/app-api/src/testHelpers/gqlHelpers.ts b/services/app-api/src/testHelpers/gqlHelpers.ts index e2f7cc706f..a775bfe2c0 100644 --- a/services/app-api/src/testHelpers/gqlHelpers.ts +++ b/services/app-api/src/testHelpers/gqlHelpers.ts @@ -262,6 +262,7 @@ const createAndUpdateTestHealthPlanPackage = async ( rateProgramIDs: [ratePrograms.reverse()[0].id], actuaryContacts: [ { + id: '123-abc', name: 'test name', titleRole: 'test title', email: 'email@example.com', @@ -275,6 +276,7 @@ const createAndUpdateTestHealthPlanPackage = async ( ] draft.addtlActuaryContacts = [ { + id: '123-addtl-abv', name: 'test name', titleRole: 'test title', email: 'email@example.com', diff --git a/services/app-graphql/src/mutations/submitRate.graphql b/services/app-graphql/src/mutations/submitRate.graphql index 87a1be9562..96c3de8365 100644 --- a/services/app-graphql/src/mutations/submitRate.graphql +++ b/services/app-graphql/src/mutations/submitRate.graphql @@ -52,6 +52,7 @@ mutation submitRate($input: SubmitRateInput!) { rateProgramIDs, rateCertificationName, certifyingActuaryContacts { + id name titleRole email @@ -59,6 +60,7 @@ mutation submitRate($input: SubmitRateInput!) { actuarialFirmOther }, addtlActuaryContacts { + id name titleRole email diff --git a/services/app-graphql/src/mutations/unlockRate.graphql b/services/app-graphql/src/mutations/unlockRate.graphql index ec47fd0003..e03faeb404 100644 --- a/services/app-graphql/src/mutations/unlockRate.graphql +++ b/services/app-graphql/src/mutations/unlockRate.graphql @@ -33,6 +33,7 @@ fragment rateRevisionFragment on RateRevision { rateProgramIDs, rateCertificationName, certifyingActuaryContacts { + id name titleRole email @@ -40,6 +41,7 @@ fragment rateRevisionFragment on RateRevision { actuarialFirmOther }, addtlActuaryContacts { + id name titleRole email diff --git a/services/app-graphql/src/queries/fetchRate.graphql b/services/app-graphql/src/queries/fetchRate.graphql index 7da1b9b208..f895425717 100644 --- a/services/app-graphql/src/queries/fetchRate.graphql +++ b/services/app-graphql/src/queries/fetchRate.graphql @@ -33,6 +33,7 @@ fragment rateRevisionFragment on RateRevision { rateProgramIDs, rateCertificationName, certifyingActuaryContacts { + id name titleRole email @@ -40,6 +41,7 @@ fragment rateRevisionFragment on RateRevision { actuarialFirmOther }, addtlActuaryContacts { + id name titleRole email diff --git a/services/app-graphql/src/schema.graphql b/services/app-graphql/src/schema.graphql index deeee2f15a..49f6a7f122 100644 --- a/services/app-graphql/src/schema.graphql +++ b/services/app-graphql/src/schema.graphql @@ -975,7 +975,7 @@ type ContractFormData { } "Either new capitation rates (NEW) or updates to previously certified capitation rates (AMENDMENT)" -enum RateType { +enum RateAmendmentType { NEW AMENDMENT } @@ -1013,6 +1013,7 @@ enum ActuarialFirm { "Contact information for the certifying or additional state actuary" type ActuaryContact { + id: ID name: String titleRole: String email: String @@ -1022,6 +1023,7 @@ type ActuaryContact { "Contact information input for the certifying or additional state actuary" input ActuaryContactInput { + id: ID name: String titleRole: String email: String @@ -1037,13 +1039,13 @@ It's used as a part of RateFormData type PackageWithSameRate { packageName: String! packageId: String! - packageStatus: String + packageStatus: HealthPlanPackageStatus } input PackageWithSameRateInput { packageName: String! packageId: String! - packageStatus: HealthPlanPackageStatus! + packageStatus: HealthPlanPackageStatus } """ @@ -1056,7 +1058,7 @@ type RateFormData { Refers to whether the state is submitting a brand new rate certification or an amendment to an existing rate certification """ - rateType: RateType + rateType: RateAmendmentType """ Can be 'RATE_CELL' or 'RATE_RANGE' These values represent on what basis the capitation rate is actuarially sound @@ -1263,7 +1265,7 @@ input RateFormDataInput { Refers to whether the state is submitting a brand new rate certification or an amendment to an existing rate certification """ - rateType: RateType + rateType: RateAmendmentType """ Can be 'RATE_CELL' or 'RATE_RANGE' These values represent on what basis the capitation rate is actuarially sound @@ -1327,7 +1329,7 @@ input RateFormDataInput { An array of additional ActuaryContacts Each element includes the the name, title/role and email """ - addtlActuaryContacts: [ActuaryContactInput!]! + addtlActuaryContacts: [ActuaryContactInput!] """ Is either OACT_TO_ACTUARY or OACT_TO_STATE It specifies whether the state wants CMS to reach out to their actuaries @@ -1345,8 +1347,8 @@ input RateFormDataInput { input SubmitRateInput { rateID: ID! - "User given submission description" - submitReason: String! + "User given submission description - defaults to Initial submission if left blank" + submitReason: String "Rate related form data to be updated with submission" formData: RateFormDataInput } diff --git a/services/app-proto/src/health_plan_form_data.proto b/services/app-proto/src/health_plan_form_data.proto index adc552ccb2..33185706af 100644 --- a/services/app-proto/src/health_plan_form_data.proto +++ b/services/app-proto/src/health_plan_form_data.proto @@ -70,6 +70,7 @@ message Contact { optional string name = 1; optional string title_role = 2; optional string email = 3; + optional string id = 4; } // ContractInfo subtypes diff --git a/services/app-web/src/common-code/proto/healthPlanFormDataProto/zodSchemas.ts b/services/app-web/src/common-code/proto/healthPlanFormDataProto/zodSchemas.ts index 537498e3cb..466fe5adc1 100644 --- a/services/app-web/src/common-code/proto/healthPlanFormDataProto/zodSchemas.ts +++ b/services/app-web/src/common-code/proto/healthPlanFormDataProto/zodSchemas.ts @@ -96,6 +96,7 @@ const stateContactSchema = z.object({ }) const actuaryContactSchema = z.object({ + id: z.string().optional(), name: z.string().optional(), titleRole: z.string().optional(), email: z.string().optional(), diff --git a/services/app-web/src/formHelpers/formatters.ts b/services/app-web/src/formHelpers/formatters.ts index a3a42e4395..e1f534a688 100644 --- a/services/app-web/src/formHelpers/formatters.ts +++ b/services/app-web/src/formHelpers/formatters.ts @@ -4,6 +4,7 @@ import { ActuaryContact, } from '../common-code/healthPlanFormDataType' import { FileItemT } from '../components' +import { GenericDocument, ActuaryContact as GQLActuaryContact } from '../gen/gqlClient' import { S3ClientT } from '../s3' import { v4 as uuidv4 } from 'uuid' @@ -21,18 +22,6 @@ const formatForApi = (attribute: string): string | null => { return attribute } -const formatYesNoForProto = ( - attribute: string | undefined -): boolean | undefined => { - if (attribute === 'YES') { - return true - } - if (attribute === 'NO') { - return false - } - return undefined -} - // Convert api data for use in form. Form fields must be a string. // Empty values as an empty string, dates in date picker as YYYY-MM-DD, boolean as "Yes" "No" values const formatForForm = ( @@ -49,9 +38,19 @@ const formatForForm = ( } } -const formatActuaryContactsForForm = (actuaryContacts?: ActuaryContact[]) => { - return actuaryContacts && actuaryContacts.length > 0 - ? actuaryContacts +// This function can be cleaned up when we move off domain types and only use graphql +const formatActuaryContactsForForm = (actuaryContacts?: ActuaryContact[] | GQLActuaryContact[]) : ActuaryContact[] => { + return actuaryContacts && actuaryContacts.length > 0 + ? actuaryContacts.map( (contact) => { + const {name, titleRole,email,actuarialFirm, actuarialFirmOther} = contact + return { + name: name ?? '', + titleRole: titleRole ?? '', + email: email ?? '', + actuarialFirmOther: actuarialFirmOther ?? undefined, + actuarialFirm: actuarialFirm ?? undefined, + } + }) : [ { name: '', @@ -63,16 +62,15 @@ const formatActuaryContactsForForm = (actuaryContacts?: ActuaryContact[]) => { ] } -const formatFormDateForDomain = (attribute: string): Date | undefined => { - if (attribute === '') { - return undefined - } - return dayjs.utc(attribute).toDate() + + +const formatFormDateForGQL = (attribute: string): string | undefined => { + return (attribute === '') ? undefined : attribute } -const formatDocumentsForDomain = ( +const formatDocumentsForGQL = ( fileItems: FileItemT[] -): SubmissionDocument[] => { +): GenericDocument[] => { return fileItems.reduce((cleanedFileItems, fileItem) => { if (fileItem.status === 'UPLOAD_ERROR') { console.info( @@ -102,14 +100,14 @@ const formatDocumentsForDomain = ( }) } return cleanedFileItems - }, [] as SubmissionDocument[]) + }, [] as GenericDocument[]) } const formatDocumentsForForm = ({ documents, getKey, }: { - documents?: SubmissionDocument[] + documents?: SubmissionDocument[] | GenericDocument[] getKey: S3ClientT['getKey'] // S3 function to call when formatting to double check we have valid documents, probably the backend should be doing this to reduce client async errors handling with bad data }): FileItemT[] => { if (!documents) return [] @@ -141,6 +139,65 @@ const formatDocumentsForForm = ({ ) } +// DEPRECATED +// Domain helpers are for HPP code. We are migrating off this in favor of directly using GQL utilities + +const formatFormDateForDomain = (attribute: string): Date | undefined => { + if (attribute === '') { + return undefined + } + return dayjs.utc(attribute).toDate() +} + + + +const formatDocumentsForDomain = ( + fileItems: FileItemT[] +): SubmissionDocument[] => { + return fileItems.reduce((cleanedFileItems, fileItem) => { + if (fileItem.status === 'UPLOAD_ERROR') { + console.info( + 'Attempting to save files that failed upload, discarding invalid files' + ) + } else if (fileItem.status === 'SCANNING_ERROR') { + console.info( + 'Attempting to save files that failed scanning, discarding invalid files' + ) + } else if (fileItem.status === 'DUPLICATE_NAME_ERROR') { + console.info( + 'Attempting to save files that are duplicate names, discarding duplicate' + ) + } else if (!fileItem.s3URL) { + console.info( + 'Attempting to save a seemingly valid file item is not yet uploaded to S3, this should not happen on form submit. Discarding file.' + ) + } else if (!fileItem.sha256) { + console.info( + 'Attempting to save a seemingly valid file item does not have a sha256 yet. this should not happen on form submit. Discarding file.' + ) + } else { + cleanedFileItems.push({ + name: fileItem.name, + s3URL: fileItem.s3URL, + sha256: fileItem.sha256, + }) + } + return cleanedFileItems + }, [] as SubmissionDocument[]) +} + +const formatYesNoForProto = ( + attribute: string | undefined +): boolean | undefined => { + if (attribute === 'YES') { + return true + } + if (attribute === 'NO') { + return false + } + return undefined +} + export { formatForApi, formatForForm, @@ -150,4 +207,6 @@ export { formatDocumentsForDomain, formatDocumentsForForm, formatActuaryContactsForForm, + formatDocumentsForGQL, + formatFormDateForGQL } diff --git a/services/app-web/src/pages/App/AppRoutes.tsx b/services/app-web/src/pages/App/AppRoutes.tsx index 800bd24810..4d2b90137c 100644 --- a/services/app-web/src/pages/App/AppRoutes.tsx +++ b/services/app-web/src/pages/App/AppRoutes.tsx @@ -37,7 +37,7 @@ import { UploadQuestions, } from '../QuestionResponse' import { GraphQLExplorer } from '../GraphQLExplorer/GraphQLExplorer' -import { RateSummary } from '../SubmissionSummary/RateSummary' +import { RateSummary } from '../RateSummary' import { RateEdit } from '../RateEdit/RateEdit' import { APIAccess } from '../APIAccess/APIAccess' @@ -109,16 +109,16 @@ const StateUserRoutes = ({ element={} /> {showRatePages && ( - } - /> - )} - {showRatePages && ( - } - /> + <> + } + /> + } + /> + )} }> {showQuestionResponse && ( diff --git a/services/app-web/src/pages/RateEdit/RateEdit.test.tsx b/services/app-web/src/pages/RateEdit/RateEdit.test.tsx index 80b1f4adf1..b9134bee8f 100644 --- a/services/app-web/src/pages/RateEdit/RateEdit.test.tsx +++ b/services/app-web/src/pages/RateEdit/RateEdit.test.tsx @@ -1,4 +1,5 @@ import { screen, waitFor } from '@testing-library/react' + import { renderWithProviders } from '../../testHelpers' import { RateEdit } from './RateEdit' import { @@ -44,7 +45,9 @@ describe('RateEdit', () => { }) await waitFor(() => { - expect(screen.queryByTestId('rate-edit')).toBeInTheDocument() + expect( + screen.queryByTestId('single-rate-edit') + ).toBeInTheDocument() }) }) }) diff --git a/services/app-web/src/pages/RateEdit/RateEdit.tsx b/services/app-web/src/pages/RateEdit/RateEdit.tsx index d66a3127ea..f193cfacb0 100644 --- a/services/app-web/src/pages/RateEdit/RateEdit.tsx +++ b/services/app-web/src/pages/RateEdit/RateEdit.tsx @@ -1,12 +1,29 @@ import React from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { useFetchRateQuery } from '../../gen/gqlClient' +import { + RateFormDataInput, + UpdateInformation, + useFetchRateQuery, + useSubmitRateMutation, +} from '../../gen/gqlClient' import { GridContainer } from '@trussworks/react-uswds' import { Loading } from '../../components' import { GenericErrorPage } from '../Errors/GenericErrorPage' +import { RateDetailsV2 } from '../StateSubmission/RateDetails/V2/RateDetailsV2' +import { RouteT, RoutesRecord } from '../../constants' +import { PageBannerAlerts } from '../StateSubmission/StateSubmissionForm' +import { useAuth } from '../../contexts/AuthContext' +import { FormContainer } from '../StateSubmission/FormContainer' import { ErrorForbiddenPage } from '../Errors/ErrorForbiddenPage' import { Error404 } from '../Errors/Error404Page' +export type SubmitOrUpdateRate = ( + rateID: string, + formInput: RateFormDataInput, + setIsSubmitting: (isSubmitting: boolean) => void, + redirect: RouteT +) => void + type RouteParams = { id: string } @@ -14,35 +31,68 @@ type RouteParams = { export const RateEdit = (): React.ReactElement => { const navigate = useNavigate() const { id } = useParams() + const { loggedInUser } = useAuth() if (!id) { throw new Error( 'PROGRAMMING ERROR: id param not set in state submission form.' ) } - const { data, loading, error } = useFetchRateQuery({ + // API handling + const { + data: fetchData, + loading: fetchLoading, + error: fetchError, + } = useFetchRateQuery({ variables: { input: { rateID: id, }, }, }) + const rate = fetchData?.fetchRate.rate - const rate = data?.fetchRate.rate + const [submitRate, { error: submitError }] = useSubmitRateMutation() + const submitRateHandler: SubmitOrUpdateRate = async ( + rateID, + formInput, + setIsSubmitting, + redirect + ) => { + setIsSubmitting(true) + try { + await submitRate({ + variables: { + input: { + rateID: rateID, + formData: formInput, + }, + }, + }) - if (loading) { + navigate(RoutesRecord[redirect]) + } catch (serverError) { + setIsSubmitting(false) + } + } + + if (fetchLoading) { return ( ) - } else if (error || !rate) { + } else if (fetchError || !rate) { //error handling for a state user that tries to access rates for a different state - if (error?.graphQLErrors[0]?.extensions?.code === 'FORBIDDEN') { + if (fetchError?.graphQLErrors[0]?.extensions?.code === 'FORBIDDEN') { return ( - + ) - } else if (error?.graphQLErrors[0]?.extensions?.code === 'NOT_FOUND') { + } else if ( + fetchError?.graphQLErrors[0]?.extensions?.code === 'NOT_FOUND' + ) { return } else { return @@ -53,13 +103,18 @@ export const RateEdit = (): React.ReactElement => { navigate(`/rates/${id}`) } + // An unlocked revision is defined by having unlockInfo on it, pull it out here if it exists + const unlockedInfo: UpdateInformation | undefined = + rate.revisions[0].unlockInfo || undefined + return ( -

- You've reached the '/rates/:id/edit' url placeholder for the - incoming standalone edit rate form -
- Ticket:{' '} - MCR-3771 -

+ + + + ) } diff --git a/services/app-web/src/pages/SubmissionSummary/RateSummary/RateSummary.test.tsx b/services/app-web/src/pages/RateSummary/RateSummary.test.tsx similarity index 93% rename from services/app-web/src/pages/SubmissionSummary/RateSummary/RateSummary.test.tsx rename to services/app-web/src/pages/RateSummary/RateSummary.test.tsx index ca00baba01..e2e6700027 100644 --- a/services/app-web/src/pages/SubmissionSummary/RateSummary/RateSummary.test.tsx +++ b/services/app-web/src/pages/RateSummary/RateSummary.test.tsx @@ -1,15 +1,15 @@ import { screen, waitFor } from '@testing-library/react' -import { renderWithProviders, testS3Client } from '../../../testHelpers' +import { renderWithProviders, testS3Client } from '../../testHelpers' import { fetchCurrentUserMock, fetchRateMockSuccess, mockValidCMSUser, mockValidStateUser, -} from '../../../testHelpers/apolloMocks' +} from '../../testHelpers/apolloMocks' import { RateSummary } from './RateSummary' -import { RoutesRecord } from '../../../constants' +import { RoutesRecord } from '../../constants' import { Route, Routes } from 'react-router-dom' -import { RateEdit } from '../../RateEdit/RateEdit' +import { RateEdit } from '../RateEdit/RateEdit' // Wrap test component in some top level routes to allow getParams to be tested const wrapInRoutes = (children: React.ReactNode) => { @@ -177,11 +177,17 @@ describe('RateSummary', () => { ) await waitFor(() => { - expect(screen.queryByTestId('rate-edit')).toBeInTheDocument() + expect( + screen.queryByTestId('single-rate-edit') + ).toBeInTheDocument() }) }) it('renders expected error page when rate ID is invalid', async () => { + const consoleWarnMock = jest + .spyOn(console, 'warn') + .mockImplementation() + renderWithProviders(wrapInRoutes(), { apolloProvider: { mocks: [ @@ -198,7 +204,7 @@ describe('RateSummary', () => { }, featureFlags: { 'rate-edit-unlock': true }, }) - + expect(consoleWarnMock).toHaveBeenCalled() // apollo testing mocks will console warn that your query is invalid - this is intentional expect(await screen.findByText('System error')).toBeInTheDocument() }) diff --git a/services/app-web/src/pages/SubmissionSummary/RateSummary/RateSummary.tsx b/services/app-web/src/pages/RateSummary/RateSummary.tsx similarity index 79% rename from services/app-web/src/pages/SubmissionSummary/RateSummary/RateSummary.tsx rename to services/app-web/src/pages/RateSummary/RateSummary.tsx index 3bc21a1f13..e2c9cee06c 100644 --- a/services/app-web/src/pages/SubmissionSummary/RateSummary/RateSummary.tsx +++ b/services/app-web/src/pages/RateSummary/RateSummary.tsx @@ -2,16 +2,16 @@ import { GridContainer, Icon, Link } from '@trussworks/react-uswds' import React, { useEffect, useState } from 'react' import { NavLink, useNavigate, useParams } from 'react-router-dom' -import { Loading } from '../../../components' -import { usePage } from '../../../contexts/PageContext' -import { useFetchRateQuery } from '../../../gen/gqlClient' -import styles from '../SubmissionSummary.module.scss' -import { GenericErrorPage } from '../../Errors/GenericErrorPage' -import { RoutesRecord } from '../../../constants' -import { SingleRateSummarySection } from '../../../components/SubmissionSummarySection/RateDetailsSummarySection/SingleRateSummarySection' -import { useAuth } from '../../../contexts/AuthContext' -import { ErrorForbiddenPage } from '../../Errors/ErrorForbiddenPage' -import { Error404 } from '../../Errors/Error404Page' +import { Loading } from '../../components' +import { usePage } from '../../contexts/PageContext' +import { useFetchRateQuery } from '../../gen/gqlClient' +import styles from '../SubmissionSummary/SubmissionSummary.module.scss' +import { GenericErrorPage } from '../Errors/GenericErrorPage' +import { RoutesRecord } from '../../constants' +import { SingleRateSummarySection } from '../../components/SubmissionSummarySection/RateDetailsSummarySection/SingleRateSummarySection' +import { useAuth } from '../../contexts/AuthContext' +import { ErrorForbiddenPage } from '../Errors/ErrorForbiddenPage' +import { Error404 } from '../Errors/Error404Page' type RouteParams = { id: string @@ -64,8 +64,12 @@ export const RateSummary = (): React.ReactElement => { } } - //Redirecting a state user to the edit page if rate is unlocked - if (loggedInUser?.role === 'STATE_USER' && rate.status === 'UNLOCKED') { + // Redirecting a state user to the edit page if rate is unlocked + if ( + data && + loggedInUser?.role === 'STATE_USER' && + rate.status === 'UNLOCKED' + ) { navigate(`/rates/${id}/edit`) } diff --git a/services/app-web/src/pages/SubmissionSummary/RateSummary/index.ts b/services/app-web/src/pages/RateSummary/index.ts similarity index 100% rename from services/app-web/src/pages/SubmissionSummary/RateSummary/index.ts rename to services/app-web/src/pages/RateSummary/index.ts diff --git a/services/app-web/src/pages/StateDashboard/StateDashboard.tsx b/services/app-web/src/pages/StateDashboard/StateDashboard.tsx index 9c3844051f..6babe917fe 100644 --- a/services/app-web/src/pages/StateDashboard/StateDashboard.tsx +++ b/services/app-web/src/pages/StateDashboard/StateDashboard.tsx @@ -18,6 +18,8 @@ import { Loading, } from '../../components' import { getCurrentRevisionFromHealthPlanPackage } from '../../gqlHelpers' +import { useLDClient } from 'launchdarkly-react-client-sdk' +import { featureFlags } from '../../common-code/featureFlags' /** * We only pull a subset of data out of the submission and revisions for display in Dashboard @@ -28,7 +30,18 @@ export const StateDashboard = (): React.ReactElement => { const { loginStatus, loggedInUser } = useAuth() const location = useLocation() - const { loading, data, error } = useIndexHealthPlanPackagesQuery() + // Force use network until we have all the APIs in use and we can reimplement cacheing + const ldClient = useLDClient() + const useContractAndRatesAPI: boolean = ldClient?.variation( + featureFlags.RATE_EDIT_UNLOCK.flag, + featureFlags.RATE_EDIT_UNLOCK.defaultValue + ) + + const { loading, data, error } = useIndexHealthPlanPackagesQuery({ + fetchPolicy: useContractAndRatesAPI + ? 'network-only' + : 'cache-and-network', + }) if (error) { handleApolloError(error, true) diff --git a/services/app-web/src/pages/StateSubmission/Contacts/ActuaryContactFields.tsx b/services/app-web/src/pages/StateSubmission/Contacts/ActuaryContactFields.tsx index c8058bb67c..0d60fa0831 100644 --- a/services/app-web/src/pages/StateSubmission/Contacts/ActuaryContactFields.tsx +++ b/services/app-web/src/pages/StateSubmission/Contacts/ActuaryContactFields.tsx @@ -4,14 +4,15 @@ import { Field, FormikErrors, FormikValues, getIn } from 'formik' import { Fieldset, FormGroup } from '@trussworks/react-uswds' import { FieldRadio, FieldTextInput } from '../../../components/Form' import { PoliteErrorMessage } from '../../../components/PoliteErrorMessage' -import { RateCertFormType } from '../RateDetails/SingleRateCert/SingleRateCert' +import { RateCertFormType } from '../RateDetails/SingleRateCert' import styles from '../StateSubmissionForm.module.scss' +import { ActuaryContact as ActuaryContactGQL } from '../../../gen/gqlClient' type FormError = FormikErrors[keyof FormikErrors] type ActuaryFormPropType = { - actuaryContact: ActuaryContact + actuaryContact: ActuaryContact | ActuaryContactGQL // GQl type for v2 API errors: FormikErrors shouldValidate: boolean fieldNamePrefix: string diff --git a/services/app-web/src/pages/StateSubmission/StateSubmissionContainer.tsx b/services/app-web/src/pages/StateSubmission/FormContainer.tsx similarity index 50% rename from services/app-web/src/pages/StateSubmission/StateSubmissionContainer.tsx rename to services/app-web/src/pages/StateSubmission/FormContainer.tsx index 4958871806..65480bf269 100644 --- a/services/app-web/src/pages/StateSubmission/StateSubmissionContainer.tsx +++ b/services/app-web/src/pages/StateSubmission/FormContainer.tsx @@ -1,16 +1,18 @@ import { GridContainer } from '@trussworks/react-uswds' import styles from './StateSubmissionForm.module.scss' -type StateSubmissionContainerProps = { +type FormContainerProps = { + id: string children: React.ReactNode & JSX.IntrinsicElements['div'] } -export const StateSubmissionContainer = ( - props: StateSubmissionContainerProps +export const FormContainer = ( + props: FormContainerProps ): React.ReactElement => { + const { id, children } = props return ( - - {props.children} + + {children} ) } diff --git a/services/app-web/src/pages/StateSubmission/New/NewStateSubmissionForm.tsx b/services/app-web/src/pages/StateSubmission/New/NewStateSubmissionForm.tsx index 92c0dd5f74..a4d47c793e 100644 --- a/services/app-web/src/pages/StateSubmission/New/NewStateSubmissionForm.tsx +++ b/services/app-web/src/pages/StateSubmission/New/NewStateSubmissionForm.tsx @@ -3,7 +3,7 @@ import React from 'react' import { DynamicStepIndicator } from '../../../components/DynamicStepIndicator' import { STATE_SUBMISSION_FORM_ROUTES } from '../../../constants/routes' import styles from '../StateSubmissionForm.module.scss' -import { StateSubmissionContainer } from '../StateSubmissionContainer' +import { FormContainer } from '../FormContainer' import { SubmissionType } from '../SubmissionType/SubmissionType' export const NewStateSubmissionForm = (): React.ReactElement => { @@ -16,9 +16,9 @@ export const NewStateSubmissionForm = (): React.ReactElement => { /> - + - + ) } diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/PackagesWithSharedRates/PackagesWithSharedRates.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/PackagesWithSharedRates.tsx similarity index 91% rename from services/app-web/src/pages/StateSubmission/RateDetails/PackagesWithSharedRates/PackagesWithSharedRates.tsx rename to services/app-web/src/pages/StateSubmission/RateDetails/PackagesWithSharedRates.tsx index 7abfee8a0f..3e3a4bde82 100644 --- a/services/app-web/src/pages/StateSubmission/RateDetails/PackagesWithSharedRates/PackagesWithSharedRates.tsx +++ b/services/app-web/src/pages/StateSubmission/RateDetails/PackagesWithSharedRates.tsx @@ -1,23 +1,20 @@ import React from 'react' -import { useIndexHealthPlanPackagesQuery } from '../../../../gen/gqlClient' -import { recordJSException } from '../../../../otelHelpers' -import { dayjs } from '../../../../common-code/dateHelpers' -import { getCurrentRevisionFromHealthPlanPackage } from '../../../../gqlHelpers' -import type { PackageOptionType } from '../../../../components/Select' -import { useStatePrograms } from '../../../../hooks' -import { packageName } from '../../../../common-code/healthPlanFormDataType' -import { FieldYesNo, PoliteErrorMessage } from '../../../../components' +import { useIndexHealthPlanPackagesQuery } from '../../../gen/gqlClient' +import { recordJSException } from '../../../otelHelpers' +import { dayjs } from '../../../common-code/dateHelpers' +import { getCurrentRevisionFromHealthPlanPackage } from '../../../gqlHelpers' +import type { PackageOptionType } from '../../../components/Select' +import { useStatePrograms } from '../../../hooks' +import { packageName } from '../../../common-code/healthPlanFormDataType' +import { FieldYesNo, PoliteErrorMessage } from '../../../components' import { FormGroup, Label, Link } from '@trussworks/react-uswds' -import { PackageSelect } from '../../../../components/Select' +import { PackageSelect } from '../../../components/Select' import { getIn, useFormikContext } from 'formik' -import { - RateCertFormType, - RateInfoArrayType, -} from '../SingleRateCert/SingleRateCert' +import { RateCertFormType, RateInfoArrayType } from './SingleRateCert' -import styles from '../../StateSubmissionForm.module.scss' -import { RoutesRecord } from '../../../../constants' +import styles from '../StateSubmissionForm.module.scss' +import { RoutesRecord } from '../../../constants' export type PackagesWithSharedRatesProps = { index: number diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/RateDetails.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/RateDetails.tsx index 41a177595f..40fa7eabe1 100644 --- a/services/app-web/src/pages/StateSubmission/RateDetails/RateDetails.tsx +++ b/services/app-web/src/pages/StateSubmission/RateDetails/RateDetails.tsx @@ -21,10 +21,7 @@ import { formatDocumentsForForm, formatForForm, } from '../../../formHelpers/formatters' -import { - RateCertFormType, - SingleRateCert, -} from './SingleRateCert/SingleRateCert' +import { RateCertFormType, SingleRateCert } from './SingleRateCert' import { useS3 } from '../../../contexts/S3Context' import { S3ClientT } from '../../../s3' import { isLoadingOrHasFileErrors } from '../../../components/FileUpload' diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/RateDetailsSchema.ts b/services/app-web/src/pages/StateSubmission/RateDetails/RateDetailsSchema.ts index 3ec15222c7..4ca791d230 100644 --- a/services/app-web/src/pages/StateSubmission/RateDetails/RateDetailsSchema.ts +++ b/services/app-web/src/pages/StateSubmission/RateDetails/RateDetailsSchema.ts @@ -13,16 +13,16 @@ const SingleRateCertSchema = (_activeFeatureFlags: FeatureFlagSettings) => Yup.object().shape({ rateDocuments: validateFileItemsListSingleUpload({ required: true }), supportingDocuments: validateFileItemsList({ required: false }), - hasSharedRateCert: Yup.string().defined('You must select yes or no'), - packagesWithSharedRateCerts: Yup.array() - .when('hasSharedRateCert', { - is: 'YES', - then: Yup.array().min( - 1, - 'You must select at least one submission' - ), - }) - .required(), + hasSharedRateCert: _activeFeatureFlags['rate-edit-unlock']? Yup.string(): Yup.string().defined('You must select yes or no'), + packagesWithSharedRateCerts: _activeFeatureFlags['rate-edit-unlock']? Yup.array().optional(): Yup.array() + .when('hasSharedRateCert', { + is: 'YES', + then: Yup.array().min( + 1, + 'You must select at least one submission' + ), + }) + .required(), rateProgramIDs: Yup.array().min(1, 'You must select a program'), rateType: Yup.string().defined( 'You must choose a rate certification type' @@ -135,7 +135,13 @@ const SingleRateCertSchema = (_activeFeatureFlags: FeatureFlagSettings) => }) const RateDetailsFormSchema = (activeFeatureFlags?: FeatureFlagSettings) => { - return Yup.object().shape({ + return activeFeatureFlags?.['rate-edit-unlock']? + Yup.object().shape({ + rates: Yup.array().of( + SingleRateCertSchema(activeFeatureFlags || {}) + ), + }): + Yup.object().shape({ rateInfos: Yup.array().of( SingleRateCertSchema(activeFeatureFlags || {}) ), diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/SingleRateCert/SingleRateCert.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/SingleRateCert.tsx similarity index 98% rename from services/app-web/src/pages/StateSubmission/RateDetails/SingleRateCert/SingleRateCert.tsx rename to services/app-web/src/pages/StateSubmission/RateDetails/SingleRateCert.tsx index 5ac5d903fc..2c20d49f24 100644 --- a/services/app-web/src/pages/StateSubmission/RateDetails/SingleRateCert/SingleRateCert.tsx +++ b/services/app-web/src/pages/StateSubmission/RateDetails/SingleRateCert.tsx @@ -13,7 +13,7 @@ import { ActuaryContact, RateCapitationType, RateType, -} from '../../../../common-code/healthPlanFormDataType' +} from '../../../common-code/healthPlanFormDataType' import { FieldRadio, FileItemT, @@ -21,23 +21,23 @@ import { PoliteErrorMessage, ProgramSelect, SectionCard, -} from '../../../../components' +} from '../../../components' -import styles from '../../StateSubmissionForm.module.scss' -import { formatUserInputDate, isDateRangeEmpty } from '../../../../formHelpers' +import styles from '../StateSubmissionForm.module.scss' +import { formatUserInputDate, isDateRangeEmpty } from '../../../formHelpers' import { ACCEPTED_RATE_SUPPORTING_DOCS_FILE_TYPES, ACCEPTED_RATE_CERTIFICATION_FILE_TYPES, -} from '../../../../components/FileUpload' -import { useS3 } from '../../../../contexts/S3Context' +} from '../../../components/FileUpload' +import { useS3 } from '../../../contexts/S3Context' import { FormikErrors, getIn, useFormikContext } from 'formik' import { ActuaryCommunicationType, SharedRateCertDisplay, -} from '../../../../common-code/healthPlanFormDataType/UnlockedHealthPlanFormDataType' -import { ActuaryContactFields } from '../../Contacts' -import { PackagesWithSharedRates } from '../PackagesWithSharedRates/PackagesWithSharedRates' +} from '../../../common-code/healthPlanFormDataType/UnlockedHealthPlanFormDataType' +import { ActuaryContactFields } from '../Contacts' +import { PackagesWithSharedRates } from './PackagesWithSharedRates' const isRateTypeEmpty = (values: RateCertFormType): boolean => values.rateType === undefined @@ -45,7 +45,7 @@ const isRateTypeAmendment = (values: RateCertFormType): boolean => values.rateType === 'AMENDMENT' export type RateCertFormType = { - id?: string + id: string key: string rateType: RateType | undefined rateCapitationType: RateCapitationType | undefined diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.test.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.test.tsx new file mode 100644 index 0000000000..34f5a08b2c --- /dev/null +++ b/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.test.tsx @@ -0,0 +1,154 @@ +import { screen, waitFor } from '@testing-library/react' +import { RateDetailsV2 } from './RateDetailsV2' +import { renderWithProviders } from '../../../../testHelpers' +import { + fetchCurrentUserMock, + rateDataMock, +} from '../../../../testHelpers/apolloMocks' +import { Route, Routes } from 'react-router-dom' +import { RoutesRecord } from '../../../../constants' + +describe('RateDetails', () => { + describe('handles a single rate', () => { + it('renders without errors', async () => { + const mockSubmit = jest.fn() + const rate = rateDataMock() + renderWithProviders( + + + } + /> + , + { + apolloProvider: { + mocks: [fetchCurrentUserMock({ statusCode: 200 })], + }, + routerProvider: { + route: `/rates/${rate.id}/edit`, + }, + } + ) + await waitFor(() => { + expect( + screen.getByText('Rate certification type') + ).toBeInTheDocument() + }) + expect( + screen.getByText('Upload one rate certification document') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Continue' }) + ).not.toHaveAttribute('aria-disabled') + }) + + it.todo('displays correct form guidance') + it.todo('loads with empty rate type and document upload fields visible') + it.todo('cannot continue without selecting rate type') + + it.todo('cannot continue without selecting rate capitation type') + + it.todo('cannot continue if no documents are added') + it.todo('progressively disclose new rate form fields as expected') + it.todo('displays program options based on current user state') + + describe('handles documents and file upload', () => { + it.todo('renders file upload') + it.todo('accepts documents on new rate') + it.todo('accepts a single file for rate cert') + + it.todo( + 'accepts multiple pdf, word, excel documents for supporting documents' + ) + it.todo('handles multiple rates') + it.todo( + 'renders add another rate button, which adds another set of rate certification fields to the form' + ) + it.todo( + 'renders remove rate certification button, which removes set of rate certification fields from the form' + ) + it.todo('accepts documents on second rate') + it.todo( + 'cannot continue without selecting rate type for a second rate' + ) + it.todo( + 'cannot continue if no documents are added to the second rate' + ) + }) + + describe('handles rates across submissions', () => { + it.todo( + 'correctly checks shared rate certification radios and selects shared package' + ) + it.todo('cannot continue when shared rate radio is unchecked') + it.todo( + 'cannot continue when shared rate radio is checked and no package is selected' + ) + }) + + describe('Continue button', () => { + it.todo('enabled when valid files are present') + + it.todo( + 'enabled when invalid files have been dropped but valid files are present' + ) + + it.todo( + 'disabled with alert after first attempt to continue with zero files' + ) + it.todo( + 'disabled with alert if previously submitted with more than one rate cert file' + ) + + it.todo( + 'disabled with alert after first attempt to continue with invalid duplicate files' + ) + + it.todo( + 'disabled with alert after first attempt to continue with invalid files' + ) + // eslint-disable-next-line jest/no-disabled-tests + it.todo( + 'disabled with alert when trying to continue while a file is still uploading' + ) + }) + + describe('Save as draft button', () => { + it.todo('enabled when valid files are present') + + it.todo( + 'enabled when invalid files have been dropped but valid files are present' + ) + it.todo( + 'when zero files present, does not trigger missing documents alert on click but still saves the in progress draft' + ) + it.todo( + 'when existing file is removed, does not trigger missing documents alert on click but still saves the in progress draft' + ) + it.todo( + 'when duplicate files present, triggers error alert on click' + ) + }) + + describe('Back button', () => { + it.todo('enabled when valid files are present') + + it.todo( + 'enabled when invalid files have been dropped but valid files are present' + ) + + it.todo( + 'when zero files present, does not trigger missing documents alert on click' + ) + + it.todo( + 'when duplicate files present, does not trigger duplicate documents alert on click and silently updates rate and supporting documents lists without duplicates' + ) + }) + }) +}) diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.tsx new file mode 100644 index 0000000000..c2d3400343 --- /dev/null +++ b/services/app-web/src/pages/StateSubmission/RateDetails/V2/RateDetailsV2.tsx @@ -0,0 +1,377 @@ +import React, { useEffect } from 'react' +import { Form as UswdsForm } from '@trussworks/react-uswds' +import { Formik, FormikErrors } from 'formik' +import { useNavigate, useParams } from 'react-router-dom' + +import styles from '../../StateSubmissionForm.module.scss' + +import { ErrorSummary } from '../../../../components' +import { RateDetailsFormSchema } from '../RateDetailsSchema' +import { PageActions } from '../../PageActions' + +import { + formatActuaryContactsForForm, + formatDocumentsForForm, + formatDocumentsForGQL, + formatForForm, + formatFormDateForGQL, +} from '../../../../formHelpers/formatters' +import { useS3 } from '../../../../contexts/S3Context' +import { S3ClientT } from '../../../../s3' +import { + FileItemT, + isLoadingOrHasFileErrors, +} from '../../../../components/FileUpload' +import { RouteT, RoutesRecord } from '../../../../constants' +import { + Rate, + RateFormDataInput, + RateRevision, +} from '../../../../gen/gqlClient' +import { SingleRateCertV2 } from './SingleRateCertV2' +import type { SubmitOrUpdateRate } from '../../../RateEdit/RateEdit' + +export type RateDetailFormValues = { + id?: string // no id if its a new rate + rateType: RateRevision['formData']['rateType'] + rateCapitationType: RateRevision['formData']['rateCapitationType'] + rateDateStart: RateRevision['formData']['rateDateStart'] + rateDateEnd: RateRevision['formData']['rateDateEnd'] + rateDateCertified: RateRevision['formData']['rateDateCertified'] + effectiveDateStart: RateRevision['formData']['amendmentEffectiveDateStart'] + effectiveDateEnd: RateRevision['formData']['amendmentEffectiveDateEnd'] + rateProgramIDs: RateRevision['formData']['rateProgramIDs'] + rateDocuments: FileItemT[] + supportingDocuments: FileItemT[] + actuaryContacts: RateRevision['formData']['certifyingActuaryContacts'] + addtlActuaryContacts: RateRevision['formData']['addtlActuaryContacts'] + actuaryCommunicationPreference: RateRevision['formData']['actuaryCommunicationPreference'] + packagesWithSharedRateCerts: RateRevision['formData']['packagesWithSharedRateCerts'] +} + +// We have a list of rates to enable multi-rate behavior +export type RateDetailFormConfig = { + rates: RateDetailFormValues[] +} + +const generateFormValues = ( + getKey: S3ClientT['getKey'], + rateRev?: RateRevision, + rateID?: string +): RateDetailFormValues => { + const rateInfo = rateRev?.formData + + return { + id: rateID, + rateType: rateInfo?.rateType, + rateCapitationType: rateInfo?.rateCapitationType, + rateDateStart: formatForForm(rateInfo?.rateDateStart), + rateDateEnd: formatForForm(rateInfo?.rateDateEnd), + rateDateCertified: formatForForm(rateInfo?.rateDateCertified), + effectiveDateStart: formatForForm( + rateInfo?.amendmentEffectiveDateStart + ), + effectiveDateEnd: formatForForm(rateInfo?.amendmentEffectiveDateEnd), + rateProgramIDs: rateInfo?.rateProgramIDs ?? [], + rateDocuments: formatDocumentsForForm({ + documents: rateInfo?.rateDocuments, + getKey: getKey, + }), + supportingDocuments: formatDocumentsForForm({ + documents: rateInfo?.supportingDocuments, + getKey: getKey, + }), + actuaryContacts: formatActuaryContactsForForm( + rateInfo?.certifyingActuaryContacts + ), + addtlActuaryContacts: formatActuaryContactsForForm( + rateInfo?.certifyingActuaryContacts + ), + actuaryCommunicationPreference: + rateInfo?.actuaryCommunicationPreference, + packagesWithSharedRateCerts: + rateInfo?.packagesWithSharedRateCerts ?? [], + } +} + +export const rateErrorHandling = ( + error: string | FormikErrors | undefined +): FormikErrors | undefined => { + if (typeof error === 'string') { + return undefined + } + return error +} + +type RateDetailsV2Props = { + showValidations?: boolean + rates: Rate[] + submitRate: SubmitOrUpdateRate + // updateRate: SubmitOrUpdateRate - will be implemented in Link Rates Epic +} +export const RateDetailsV2 = ({ + showValidations = false, + rates, + submitRate, +}: RateDetailsV2Props): React.ReactElement => { + const navigate = useNavigate() + const { id } = useParams() + if (!id) { + throw new Error( + 'PROGRAMMING ERROR: id param not set in rate edit form.' + ) + } + const { getKey } = useS3() + + // Form validation + const [shouldValidate, setShouldValidate] = React.useState(showValidations) + const rateDetailsFormSchema = RateDetailsFormSchema({ + 'rate-edit-unlock': true, + }) + + // UI focus state management + const [focusErrorSummaryHeading, setFocusErrorSummaryHeading] = + React.useState(false) + const errorSummaryHeadingRef = React.useRef(null) + + useEffect(() => { + // Focus the error summary heading only if we are displaying + // validation errors and the heading element exists + if (focusErrorSummaryHeading && errorSummaryHeadingRef.current) { + errorSummaryHeadingRef.current.focus() + } + setFocusErrorSummaryHeading(false) + }, [focusErrorSummaryHeading]) + + const previousDocuments: string[] = [] + + // Formik setup + const initialValues: RateDetailFormConfig = { + rates: + rates.length > 0 + ? rates.map((rate) => + generateFormValues( + getKey, + rate.draftRevision ?? undefined, + rate.id + ) + ) + : [ + generateFormValues( + getKey, + rates[0].draftRevision ?? undefined, + rates[0].id + ), + ], + } + + const handlePageAction = async ( + rates: RateDetailFormValues[], + setSubmitting: (isSubmitting: boolean) => void, // formik setSubmitting + options: { + type: 'SAVE_AS_DRAFT' | 'CANCEL' | 'CONTINUE' + redirectPath: RouteT + } + ) => { + if (options.type === 'CONTINUE') { + const fileErrorsNeedAttention = rates.some((rateForm) => + isLoadingOrHasFileErrors( + rateForm.supportingDocuments.concat(rateForm.rateDocuments) + ) + ) + + if (fileErrorsNeedAttention) { + // make inline field errors visible so user can correct documents, direct user focus to errors, and manually exit formik submit + setShouldValidate(true) + setFocusErrorSummaryHeading(true) + setSubmitting(false) + return + } + } + + const gqlFormDatas: Array<{ id?: string } & RateFormDataInput> = + rates.map((form) => { + return { + id: form.id, + rateType: form.rateType, + rateCapitationType: form.rateCapitationType, + rateDocuments: formatDocumentsForGQL(form.rateDocuments), + supportingDocuments: formatDocumentsForGQL( + form.supportingDocuments + ), + rateDateStart: formatFormDateForGQL(form.rateDateStart), + rateDateEnd: formatFormDateForGQL(form.rateDateEnd), + rateDateCertified: formatFormDateForGQL( + form.rateDateCertified + ), + amendmentEffectiveDateStart: formatFormDateForGQL( + form.effectiveDateStart + ), + amendmentEffectiveDateEnd: formatFormDateForGQL( + form.effectiveDateEnd + ), + rateProgramIDs: form.rateProgramIDs, + certifyingActuaryContacts: form.actuaryContacts, + addtlActuaryContacts: form.addtlActuaryContacts, + actuaryCommunicationPreference: + form.actuaryCommunicationPreference, + packagesWithSharedRateCerts: + form.packagesWithSharedRateCerts, + } + }) + + const { id, ...formData } = gqlFormDatas[0] // only grab the first rate in the array because multi-rates functionality not added yet. This will be part of Link Rates epic + + if (options.type === 'CONTINUE' && id) { + await submitRate(id, formData, setSubmitting, 'DASHBOARD') + } else if (options.type === 'CONTINUE' && !id) { + throw new Error( + 'Rate create and update for a new rate is not yet implemented. This will be part of Link Rates epic.' + ) + } else if (options.type === 'SAVE_AS_DRAFT') { + throw new Error( + 'Rate save as draft is not possible. This will be part of Link Rates epic.' + ) + } else { + navigate(RoutesRecord[options.redirectPath]) + } + } + + // Due to multi-rates we have extra handling around how error summary appears + // Error summary object keys will be used as DOM focus point from error-summary. Must be valid html selector + // Error summary object values will be used as messages displays in error summary + const generateErrorSummaryErrors = ( + errors: FormikErrors + ) => { + const rateErrors = errors.rates + const errorObject: { [field: string]: string } = {} + + if (rateErrors && Array.isArray(rateErrors)) { + rateErrors.forEach((rateError, index) => { + if (!rateError) return + + Object.entries(rateError).forEach(([field, value]) => { + if (typeof value === 'string') { + //rateProgramIDs error message needs a # proceeding the key name because this is the only way to be able to link to the Select component element see comments in ErrorSummaryMessage component. + const errorKey = + field === 'rateProgramIDs' + ? `#rates.${index}.${field}` + : `rates.${index}.${field}` + errorObject[errorKey] = value + } + // If the field is actuaryContacts then the value should be an array with at least one object of errors + if ( + field === 'actuaryContacts' && + Array.isArray(value) && + Array.length > 0 + ) { + // Rate certifications only have 1 certifying actuary contact + const actuaryContact = value[0] + Object.entries(actuaryContact).forEach( + ([contactField, contactValue]) => { + if (typeof contactValue === 'string') { + const errorKey = `rates.${index}.actuaryContacts.0.${contactField}` + errorObject[errorKey] = contactValue + } + } + ) + } + }) + }) + } + + return errorObject + } + + return ( + { + return handlePageAction(rateFormValues.rates, setSubmitting, { + type: 'CONTINUE', + redirectPath: 'DASHBOARD_SUBMISSIONS', + }) + }} + validationSchema={rateDetailsFormSchema} + > + {({ + values: rateFormValues, + errors, + dirty, + handleSubmit, + isSubmitting, + setSubmitting, + }) => { + return ( + <> + { + setShouldValidate(true) + setFocusErrorSummaryHeading(true) + handleSubmit(e) + }} + > +
+ Rate Details + {shouldValidate && ( + + )} + +
+ { + if (dirty) { + await handlePageAction( + rateFormValues.rates, + setSubmitting, + { + type: 'CANCEL', + redirectPath: + 'DASHBOARD_SUBMISSIONS', + } + ) + } else { + navigate( + RoutesRecord.DASHBOARD_SUBMISSIONS + ) + } + }} + saveAsDraftOnClick={async () => { + await handlePageAction( + rateFormValues.rates, + setSubmitting, + { + type: 'SAVE_AS_DRAFT', + redirectPath: + 'DASHBOARD_SUBMISSIONS', + } + ) + }} + disableContinue={ + shouldValidate && + !!Object.keys(errors).length + } + actionInProgress={isSubmitting} + /> +
+ + ) + }} +
+ ) +} diff --git a/services/app-web/src/pages/StateSubmission/RateDetails/V2/SingleRateCertV2.tsx b/services/app-web/src/pages/StateSubmission/RateDetails/V2/SingleRateCertV2.tsx new file mode 100644 index 0000000000..beaf32fc52 --- /dev/null +++ b/services/app-web/src/pages/StateSubmission/RateDetails/V2/SingleRateCertV2.tsx @@ -0,0 +1,526 @@ +import React from 'react' +import { + Button, + DatePicker, + DateRangePicker, + Fieldset, + FormGroup, + Label, + Link, +} from '@trussworks/react-uswds' +import classnames from 'classnames' +import { + FieldRadio, + FileUpload, + PoliteErrorMessage, + ProgramSelect, + SectionCard, +} from '../../../../components' + +import styles from '../../StateSubmissionForm.module.scss' +import { formatUserInputDate, isDateRangeEmpty } from '../../../../formHelpers' +import { + ACCEPTED_RATE_SUPPORTING_DOCS_FILE_TYPES, + ACCEPTED_RATE_CERTIFICATION_FILE_TYPES, +} from '../../../../components/FileUpload' +import { useS3 } from '../../../../contexts/S3Context' + +import { FormikErrors, getIn, useFormikContext } from 'formik' +import { ActuaryContactFields } from '../../Contacts' +import { RateDetailFormValues, RateDetailFormConfig } from './RateDetailsV2' + +const isRateTypeEmpty = (rateForm: RateDetailFormValues): boolean => + rateForm.rateType === undefined +const isRateTypeAmendment = (rateForm: RateDetailFormValues): boolean => + rateForm.rateType === 'AMENDMENT' + +export type SingleRateFormError = + FormikErrors[keyof FormikErrors] + +type MultiRatesConfig = { + reassignNewRateRef: ((el: HTMLInputElement) => void) | undefined + removeSelf: () => void // callback to Formik FieldArray to imperatively remove this rate from overall list and refocus on add new rate button +} + +type SingleRateCertV2Props = { + rateForm: RateDetailFormValues + shouldValidate: boolean + index: number // defaults to 0 + previousDocuments: string[] // this only passed in to ensure S3 deleteFile doesn't remove valid files for previous revisions + multiRatesConfig?: MultiRatesConfig //t his is for use with Linked Rates and multi-rates UI +} + +const RateDatesErrorMessage = ({ + startDate, + endDate, + startDateError, + endDateError, + shouldValidate, +}: { + shouldValidate: boolean + startDate?: string + endDate?: string + startDateError?: string // yup validation message + endDateError?: string // yup validation message +}): React.ReactElement => { + const hasError = shouldValidate && (startDateError || endDateError) + + // Error messages have hierarchy + // preference to show message for totally empty date, then errors for start date, then for end date + const validationErrorMessage = hasError + ? isDateRangeEmpty(startDate, endDate) + ? 'You must provide a start and an end date' + : startDateError ?? endDateError + : null + + return {validationErrorMessage} +} + +export const SingleRateCertV2 = ({ + rateForm, + shouldValidate, + multiRatesConfig, + index = 0, + previousDocuments, +}: SingleRateCertV2Props): React.ReactElement => { + // page level setup + const { handleDeleteFile, handleUploadFile, handleScanFile } = useS3() + const key = rateForm.id + const displayAsStandaloneRate = multiRatesConfig === undefined + const fieldNamePrefix = `rates.${index}` + const rateCertNumber = index + 1 + const { errors, setFieldValue } = useFormikContext() + + const showFieldErrors = ( + fieldName: keyof RateDetailFormValues + ): string | undefined => { + if (!shouldValidate) return undefined + return getIn(errors, `${fieldNamePrefix}.${fieldName}`) + } + + return ( + +

+ {displayAsStandaloneRate + ? `Rate certification` + : `Rate certification ${rateCertNumber}`} +

+
+ + + + Document definitions and requirements + + + {`Upload only one rate certification document. Additional rates can be added later.`} + + + + This input only accepts one file in PDF, + DOC, or DOCX format. + + + } + accept={ACCEPTED_RATE_CERTIFICATION_FILE_TYPES} + initialItems={rateForm.rateDocuments} + uploadFile={(file) => + handleUploadFile(file, 'HEALTH_PLAN_DOCS') + } + scanFile={(key) => + handleScanFile(key, 'HEALTH_PLAN_DOCS') + } + deleteFile={(key) => + handleDeleteFile( + key, + 'HEALTH_PLAN_DOCS', + previousDocuments + ) + } + innerInputRef={multiRatesConfig?.reassignNewRateRef} + onFileItemsUpdate={({ fileItems }) => + setFieldValue( + `${fieldNamePrefix}.rateDocuments`, + fileItems + ) + } + /> + + + + + + Document definitions and requirements + + + {`Upload any supporting documents for Rate certification ${rateCertNumber}`} + + + Additional rates can be added later. + + + + This input only accepts PDF, CSV, DOC, DOCX, + XLS, XLSX files. + + + } + accept={ACCEPTED_RATE_SUPPORTING_DOCS_FILE_TYPES} + initialItems={rateForm.supportingDocuments} + uploadFile={(file) => + handleUploadFile(file, 'HEALTH_PLAN_DOCS') + } + scanFile={(key) => + handleScanFile(key, 'HEALTH_PLAN_DOCS') + } + deleteFile={(key) => + handleDeleteFile( + key, + 'HEALTH_PLAN_DOCS', + previousDocuments + ) + } + onFileItemsUpdate={({ fileItems }) => + setFieldValue( + `${fieldNamePrefix}.supportingDocuments`, + fileItems + ) + } + /> + + + + + Required + + + {showFieldErrors('rateProgramIDs')} + + + + + +
+ + Required + + + {showFieldErrors('rateType')} + + + + Rate certification type definitions + + + +
+
+ + +
+

+ Does the actuary certify capitation rates + specific to each rate cell or a rate range? +

+ + Required + +

+ See 42 CFR ยงยง 438.4(b) and 438.4(c) +

+ + } + role="radiogroup" + aria-required + > + + {showFieldErrors('rateCapitationType')} + + + +
+
+ + {!isRateTypeEmpty(rateForm) && ( + <> + +
+ + Required + + + + + setFieldValue( + `${fieldNamePrefix}.rateDateStart`, + formatUserInputDate(val) + ), + }} + endDateHint="mm/dd/yyyy" + endDateLabel="End date" + endDatePickerProps={{ + disabled: false, + id: `${fieldNamePrefix}.rateDateEnd`, + name: `${fieldNamePrefix}.rateDateEnd`, + 'aria-required': true, + defaultValue: rateForm.rateDateEnd, + onChange: (val) => + setFieldValue( + `${fieldNamePrefix}.rateDateEnd`, + formatUserInputDate(val) + ), + }} + /> +
+
+ + {isRateTypeAmendment(rateForm) && ( + <> + +
+ + Required + + + + + setFieldValue( + `${fieldNamePrefix}.effectiveDateStart`, + formatUserInputDate(val) + ), + }} + endDateHint="mm/dd/yyyy" + endDateLabel="End date" + endDatePickerProps={{ + disabled: false, + id: `${fieldNamePrefix}.effectiveDateEnd`, + name: `${fieldNamePrefix}.effectiveDateEnd`, + 'aria-required': true, + defaultValue: + rateForm.effectiveDateEnd, + onChange: (val) => + setFieldValue( + `${fieldNamePrefix}.effectiveDateEnd`, + formatUserInputDate(val) + ), + }} + /> +
+
+ + )} + + + + Required + +
+ mm/dd/yyyy +
+ + {showFieldErrors('rateDateCertified')} + + + + setFieldValue( + `${fieldNamePrefix}.rateDateCertified`, + formatUserInputDate(val) + ) + } + /> +
+ + )} + + + + + {index >= 1 && multiRatesConfig && ( + + )} +
+
+ ) +} diff --git a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx index 38d4589f5f..12e1283682 100644 --- a/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx +++ b/services/app-web/src/pages/StateSubmission/StateSubmissionForm.tsx @@ -24,7 +24,7 @@ import { } from '../../constants/routes' import { getRelativePath } from '../../routeHelpers' import { useFetchHealthPlanPackageWrapper } from '../../gqlHelpers' -import { StateSubmissionContainer } from './StateSubmissionContainer' +import { FormContainer } from './FormContainer' import { ContractDetails } from './ContractDetails' import { RateDetails } from './RateDetails' import { Contacts } from './Contacts' @@ -55,7 +55,7 @@ const getRelativePathFromNestedRoute = (formRouteType: RouteT): string => targetPath: RoutesRecord[formRouteType], }) -const PageBannerAlerts = ({ +export const PageBannerAlerts = ({ showPageErrorMessage, loggedInUser, unlockedInfo, @@ -251,7 +251,7 @@ export const StateSubmissionForm = (): React.ReactElement => { showPageErrorMessage={showPageErrorMessage} /> - + { /> } /> - + ) } diff --git a/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.tsx b/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.tsx index dc39c48d6b..3cedab9dc8 100644 --- a/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.tsx +++ b/services/app-web/src/pages/SubmissionSideNav/SubmissionSideNav.tsx @@ -63,6 +63,7 @@ export const SubmissionSideNav = () => { featureFlags.CMS_QUESTIONS.flag, featureFlags.CMS_QUESTIONS.defaultValue ) + const showSidebar = showQuestionResponse && QUESTION_RESPONSE_SHOW_SIDEBAR_ROUTES.includes(routeName) diff --git a/services/app-web/src/pages/SubmissionSummary/index.ts b/services/app-web/src/pages/SubmissionSummary/index.ts index 9694162a2b..055197426d 100644 --- a/services/app-web/src/pages/SubmissionSummary/index.ts +++ b/services/app-web/src/pages/SubmissionSummary/index.ts @@ -1,2 +1,2 @@ export { SubmissionSummary } from './SubmissionSummary' -export { RateSummary } from './RateSummary' +export { RateSummary } from '../RateSummary' diff --git a/services/app-web/src/testHelpers/apolloMocks/rateDataMock.ts b/services/app-web/src/testHelpers/apolloMocks/rateDataMock.ts index 6864845ee6..b5f0946002 100644 --- a/services/app-web/src/testHelpers/apolloMocks/rateDataMock.ts +++ b/services/app-web/src/testHelpers/apolloMocks/rateDataMock.ts @@ -118,6 +118,7 @@ const rateRevisionDataMock = (data?: Partial): RateRevision => { certifyingActuaryContacts: [ { __typename: 'ActuaryContact', + id: '123-cert-actuary', name: 'Actuary Contact Person', titleRole: 'Actuary Contact Title', email: 'actuarycontact@example.com', @@ -128,6 +129,7 @@ const rateRevisionDataMock = (data?: Partial): RateRevision => { addtlActuaryContacts: [ { __typename: 'ActuaryContact', + id: '123-additional-actuary', name: 'Additional actuary name', titleRole: 'Additional actuary title', email: 'additonalactuary@example.com', From af37f9ccd953d53732f5c9dee1ee00d1174c06a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Feb 2024 11:38:22 -0800 Subject: [PATCH 4/8] Bump @types/uuid from 9.0.1 to 9.0.8 (#2249) Bumps [@types/uuid](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/uuid) from 9.0.1 to 9.0.8. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/uuid) --- updated-dependencies: - dependency-name: "@types/uuid" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6d43e28d4b..313212ee41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12663,9 +12663,9 @@ integrity sha512-FKvKIqRaykZtd4n47LbK/W/5fhQQ1X7cxxzG9A48h0BGN+S04NH7ervcCjM8tyR0lyGru83FAHSmw2ObgKoESg== "@types/uuid@^9.0.0": - version "9.0.1" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.1.tgz#98586dc36aee8dacc98cc396dbca8d0429647aa6" - integrity sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA== + version "9.0.8" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" + integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA== "@types/webpack-env@^1.16.0": version "1.18.0" From 92305f64ea1fc3d352078c0c495737869e3f289b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Feb 2024 07:40:08 -0600 Subject: [PATCH 5/8] Bump react-router-dom from 6.14.2 to 6.22.0 (#2241) Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 6.14.2 to 6.22.0. - [Release notes](https://github.com/remix-run/react-router/releases) - [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md) - [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@6.22.0/packages/react-router-dom) --- updated-dependencies: - dependency-name: react-router-dom dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index 313212ee41..671aa9244b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9425,10 +9425,10 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== -"@remix-run/router@1.7.2": - version "1.7.2" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.2.tgz#cba1cf0a04bc04cb66027c51fa600e9cbc388bc8" - integrity sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A== +"@remix-run/router@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.15.0.tgz#461a952c2872dd82c8b2e9b74c4dfaff569123e2" + integrity sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ== "@repeaterjs/repeater@^3.0.4": version "3.0.5" @@ -27730,19 +27730,19 @@ react-refresh@^0.11.0: integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== react-router-dom@^6.5.0: - version "6.14.2" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.14.2.tgz#88f520118b91aa60233bd08dbd3fdcaea3a68488" - integrity sha512-5pWX0jdKR48XFZBuJqHosX3AAHjRAzygouMTyimnBPOLdY3WjzUSKhus2FVMihUFWzeLebDgr4r8UeQFAct7Bg== + version "6.22.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.22.0.tgz#177c8bd27146decbb991eafb5df159f7a9f70035" + integrity sha512-z2w+M4tH5wlcLmH3BMMOMdrtrJ9T3oJJNsAlBJbwk+8Syxd5WFJ7J5dxMEW0/GEXD1BBis4uXRrNIz3mORr0ag== dependencies: - "@remix-run/router" "1.7.2" - react-router "6.14.2" + "@remix-run/router" "1.15.0" + react-router "6.22.0" -react-router@6.14.2: - version "6.14.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.2.tgz#1f60994d8c369de7b8ba7a78d8f7ec23df76b300" - integrity sha512-09Zss2dE2z+T1D03IheqAFtK4UzQyX8nFPWx6jkwdYzGLXd5ie06A6ezS2fO6zJfEb/SpG6UocN2O1hfD+2urQ== +react-router@6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.22.0.tgz#a22b44851a79dafc6b944cb418db3e80622b9be1" + integrity sha512-q2yemJeg6gw/YixRlRnVx6IRJWZD6fonnfZhN1JIOhV2iJCPeRNSH3V1ISwHf+JWcESzLC3BOLD1T07tmO5dmg== dependencies: - "@remix-run/router" "1.7.2" + "@remix-run/router" "1.15.0" react-scripts@5.0.1: version "5.0.1" From a1ce89440610843096aed2a9eb5b5373976d800f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Feb 2024 10:05:20 -0600 Subject: [PATCH 6/8] Bump eslint from 8.49.0 to 8.56.0 (#2251) Bumps [eslint](https://github.com/eslint/eslint) from 8.49.0 to 8.56.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v8.49.0...v8.56.0) --- updated-dependencies: - dependency-name: eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 79 +++++++++++++++++++++++-------------------------------- 1 file changed, 33 insertions(+), 46 deletions(-) diff --git a/yarn.lock b/yarn.lock index 671aa9244b..d45a89e44c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6463,10 +6463,10 @@ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.0.tgz#11195513186f68d42fbf449f9a7136b2c0c92005" integrity sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg== -"@eslint/eslintrc@^2.1.2": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.2.tgz#c6936b4b328c64496692f76944e755738be62396" - integrity sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g== +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== dependencies: ajv "^6.12.4" debug "^4.3.2" @@ -6478,10 +6478,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.49.0": - version "8.49.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333" - integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w== +"@eslint/js@8.56.0": + version "8.56.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b" + integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== "@floating-ui/core@^1.0.4": version "1.0.4" @@ -7463,13 +7463,13 @@ "@hapi/bourne" "^3.0.0" "@hapi/hoek" "^11.0.2" -"@humanwhocodes/config-array@^0.11.11": - version "0.11.11" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844" - integrity sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA== +"@humanwhocodes/config-array@^0.11.13": + version "0.11.14" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" + integrity sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg== dependencies: - "@humanwhocodes/object-schema" "^1.2.1" - debug "^4.1.1" + "@humanwhocodes/object-schema" "^2.0.2" + debug "^4.3.1" minimatch "^3.0.5" "@humanwhocodes/module-importer@^1.0.1": @@ -7477,10 +7477,10 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/object-schema@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" - integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@humanwhocodes/object-schema@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" + integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw== "@hutson/parse-repository-url@^3.0.0": version "3.0.2" @@ -12955,6 +12955,11 @@ "@typescript-eslint/types" "6.21.0" eslint-visitor-keys "^3.4.1" +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + "@vendia/serverless-express@^4.3.9": version "4.10.1" resolved "https://registry.yarnpkg.com/@vendia/serverless-express/-/serverless-express-4.10.1.tgz#6489cd179734f1cd690b93a90ae2e83ae8adc639" @@ -18410,17 +18415,18 @@ eslint-webpack-plugin@^3.1.1: schema-utils "^4.0.0" eslint@^8.3.0: - version "8.49.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.49.0.tgz#09d80a89bdb4edee2efcf6964623af1054bf6d42" - integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ== + version "8.56.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.56.0.tgz#4957ce8da409dc0809f99ab07a1b94832ab74b15" + integrity sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^2.1.2" - "@eslint/js" "8.49.0" - "@humanwhocodes/config-array" "^0.11.11" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.56.0" + "@humanwhocodes/config-array" "^0.11.13" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" ajv "^6.12.4" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -20937,12 +20943,7 @@ ignore@^4.0.3: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.0.4, ignore@^5.1.4, ignore@^5.1.8, ignore@^5.2.0: - version "5.2.4" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" - integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== - -ignore@^5.2.4, ignore@^5.3.0: +ignore@^5.0.4, ignore@^5.1.4, ignore@^5.1.8, ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== @@ -24527,7 +24528,7 @@ minimatch@3.0.5: dependencies: brace-expansion "^1.1.7" -minimatch@9.0.3, minimatch@^9.0.1: +minimatch@9.0.3, minimatch@^9.0.0, minimatch@^9.0.1: version "9.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== @@ -24562,13 +24563,6 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.0.tgz#bfc8e88a1c40ffd40c172ddac3decb8451503b56" - integrity sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w== - dependencies: - brace-expansion "^2.0.1" - minimist-options@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" @@ -30087,14 +30081,7 @@ strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" -strip-ansi@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" - integrity sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw== - dependencies: - ansi-regex "^6.0.1" - -strip-ansi@^7.1.0: +strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== From 5280631c0f49505ce30f02d9130ad5862d62bafe Mon Sep 17 00:00:00 2001 From: Mojo Talantikite Date: Wed, 14 Feb 2024 18:20:50 -0500 Subject: [PATCH 7/8] bump to latest python runtime (#2257) --- services/postgres/serverless.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/postgres/serverless.yml b/services/postgres/serverless.yml index 2e516943df..1c90f7ea32 100644 --- a/services/postgres/serverless.yml +++ b/services/postgres/serverless.yml @@ -11,7 +11,7 @@ plugins: provider: name: aws - runtime: python3.7 + runtime: python3.12 region: us-east-1 iam: role: @@ -85,7 +85,7 @@ package: functions: rotator: - runtime: python3.7 + runtime: python3.12 handler: lambda_function.lambda_handler description: Conducts an AWS SecretsManager secret rotation for RDS PostgreSQL using single user rotation scheme timeout: 30 From 3456f2288a879d72a942ef47407d211d48264a67 Mon Sep 17 00:00:00 2001 From: Mojo Talantikite Date: Wed, 14 Feb 2024 21:15:53 -0500 Subject: [PATCH 8/8] route53 to oidc (#2260) --- services/github-oidc/serverless.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/services/github-oidc/serverless.yml b/services/github-oidc/serverless.yml index 0cfbbb0e63..2f6357ab87 100644 --- a/services/github-oidc/serverless.yml +++ b/services/github-oidc/serverless.yml @@ -53,6 +53,7 @@ params: - 'kms:*' - 'lambda:*' - 'logs:*' + - 'route53:*' - 'rds:*' - 'secretsmanager:*' - 'ssm:*'