diff --git a/services/app-api/prisma/migrations/20250123013426_update_email_settings_default_values/migration.sql b/services/app-api/prisma/migrations/20250123013426_update_email_settings_default_values/migration.sql new file mode 100644 index 0000000000..ee5ddde094 --- /dev/null +++ b/services/app-api/prisma/migrations/20250123013426_update_email_settings_default_values/migration.sql @@ -0,0 +1,16 @@ +BEGIN; + +-- AlterTable +ALTER TABLE "EmailSettings" ALTER COLUMN "emailSource" SET DEFAULT 'mc-review-qa@truss.works', +ALTER COLUMN "devReviewTeamEmails" SET DEFAULT ARRAY['']::TEXT[], +ALTER COLUMN "helpDeskEmail" SET DEFAULT ARRAY['Helpdesk ']::TEXT[]; + +-- UpdateRecord +UPDATE "EmailSettings" +SET + "emailSource" = 'mc-review-qa@truss.works', + "devReviewTeamEmails" = ARRAY['']::TEXT[], + "helpDeskEmail" = ARRAY['Helpdesk ']::TEXT[] +WHERE id = 1; + +COMMIT; \ No newline at end of file diff --git a/services/app-api/prisma/migrations/20250124195012_fix_typo_in_default_email_settings_values/migration.sql b/services/app-api/prisma/migrations/20250124195012_fix_typo_in_default_email_settings_values/migration.sql new file mode 100644 index 0000000000..ccfa2770fc --- /dev/null +++ b/services/app-api/prisma/migrations/20250124195012_fix_typo_in_default_email_settings_values/migration.sql @@ -0,0 +1,12 @@ +BEGIN; + +-- AlterTable +ALTER TABLE "EmailSettings" ALTER COLUMN "devReviewTeamEmails" SET DEFAULT ARRAY['Dev Team ']::TEXT[]; + +-- UpdateRecord +UPDATE "EmailSettings" +SET + "devReviewTeamEmails" = ARRAY['Dev Team ']::TEXT[] +WHERE id = 1; + +COMMIT; \ No newline at end of file diff --git a/services/app-api/prisma/migrations/20250124214902_fix_email_display_name_in_email_settings/migration.sql b/services/app-api/prisma/migrations/20250124214902_fix_email_display_name_in_email_settings/migration.sql new file mode 100644 index 0000000000..bb659bb036 --- /dev/null +++ b/services/app-api/prisma/migrations/20250124214902_fix_email_display_name_in_email_settings/migration.sql @@ -0,0 +1,28 @@ +BEGIN; + +-- AlterTable +ALTER TABLE "EmailSettings" ALTER COLUMN "emailSource" SET DEFAULT 'mc-review@cms.hhs.gov', +ALTER COLUMN "devReviewTeamEmails" SET DEFAULT ARRAY['mc-review-qa+DevTeam@truss.works']::TEXT[], +ALTER COLUMN "cmsReviewHelpEmailAddress" SET DEFAULT ARRAY['mc-review-qa+MCOGDMCOActionsHelp@truss.works']::TEXT[], +ALTER COLUMN "cmsRateHelpEmailAddress" SET DEFAULT ARRAY['mc-review-qa+MMCratesettingHelp@truss.works']::TEXT[], +ALTER COLUMN "oactEmails" SET DEFAULT ARRAY['mc-review-qa+OACTdev1@truss.works', 'mc-review-qa+OACTdev2@truss.works']::TEXT[], +ALTER COLUMN "dmcpReviewEmails" SET DEFAULT ARRAY['mc-review-qa+DMCPreviewdev1@truss.works', 'mc-review-qa+DMCPreivewdev2@truss.works']::TEXT[], +ALTER COLUMN "dmcpSubmissionEmails" SET DEFAULT ARRAY['mc-review-qa+DMCPsubmissiondev1@truss.works', 'mc-review-qa+DMCPsubmissiondev2@truss.works']::TEXT[], +ALTER COLUMN "dmcoEmails" SET DEFAULT ARRAY['mc-review-qa+DMCO1@truss.works', 'mc-review-qa+DMCO2@truss.works']::TEXT[], +ALTER COLUMN "helpDeskEmail" SET DEFAULT ARRAY['mc-review-qa+MC_Review_HelpDesk@truss.works']::TEXT[]; + +-- Update existing record +UPDATE "EmailSettings" +SET + "emailSource" = 'mc-review@cms.hhs.gov', + "devReviewTeamEmails" = ARRAY['mc-review-qa+DevTeam@truss.works']::TEXT[], + "cmsReviewHelpEmailAddress" = ARRAY['mc-review-qa+MCOGDMCOActionsHelp@truss.works']::TEXT[], + "cmsRateHelpEmailAddress" = ARRAY['mc-review-qa+MMCratesettingHelp@truss.works']::TEXT[], + "oactEmails" = ARRAY['mc-review-qa+OACTdev1@truss.works', 'mc-review-qa+OACTdev2@truss.works']::TEXT[], + "dmcpReviewEmails" = ARRAY['mc-review-qa+DMCPreviewdev1@truss.works', 'mc-review-qa+DMCPreivewdev2@truss.works']::TEXT[], + "dmcpSubmissionEmails" = ARRAY['mc-review-qa+DMCPsubmissiondev1@truss.works', 'mc-review-qa+DMCPsubmissiondev2@truss.works']::TEXT[], + "dmcoEmails" = ARRAY['mc-review-qa+DMCO1@truss.works', 'mc-review-qa+DMCO2@truss.works']::TEXT[], + "helpDeskEmail" = ARRAY['mc-review-qa+MC_Review_HelpDesk@truss.works']::TEXT[] +WHERE id = 1; + +COMMIT; \ No newline at end of file diff --git a/services/app-api/prisma/schema.prisma b/services/app-api/prisma/schema.prisma index a0bbfa0d57..471eddb919 100644 --- a/services/app-api/prisma/schema.prisma +++ b/services/app-api/prisma/schema.prisma @@ -30,14 +30,14 @@ model ApplicationSettings { model EmailSettings { id Int @id @default(1) emailSource String @default("mc-review@cms.hhs.gov") - devReviewTeamEmails String[] @default(["mc-review@cms.hhs.gov"]) - cmsReviewHelpEmailAddress String[] @default(["MCOGDMCOActions "]) - cmsRateHelpEmailAddress String[] @default(["MMCratesetting "]) - oactEmails String[] @default(["OACT Dev1 ","OACT Dev2 "]) - dmcpReviewEmails String[] @default(["DMCP Review Dev1 ", "DMCP Review Dev2 "]) - dmcpSubmissionEmails String[] @default(["DMCP Submission Dev1 ", "DMCP Submission Dev2 "]) - dmcoEmails String[] @default(["DMCO Dev1 ", "DMCO Dev2 "]) - helpDeskEmail String[] @default(["MC_Review_HelpDesk@cms.hhs.gov"]) + devReviewTeamEmails String[] @default(["mc-review-qa+DevTeam@truss.works"]) + cmsReviewHelpEmailAddress String[] @default(["mc-review-qa+MCOGDMCOActionsHelp@truss.works"]) + cmsRateHelpEmailAddress String[] @default(["mc-review-qa+MMCratesettingHelp@truss.works"]) + oactEmails String[] @default(["mc-review-qa+OACTdev1@truss.works", "mc-review-qa+OACTdev2@truss.works"]) + dmcpReviewEmails String[] @default(["mc-review-qa+DMCPreviewdev1@truss.works", "mc-review-qa+DMCPreivewdev2@truss.works"]) + dmcpSubmissionEmails String[] @default(["mc-review-qa+DMCPsubmissiondev1@truss.works", "mc-review-qa+DMCPsubmissiondev2@truss.works"]) + dmcoEmails String[] @default(["mc-review-qa+DMCO1@truss.works", "mc-review-qa+DMCO2@truss.works"]) + helpDeskEmail String[] @default(["mc-review-qa+MC_Review_HelpDesk@truss.works"]) applicationSettings ApplicationSettings? @relation(fields: [applicationSettingsId], references: [id]) applicationSettingsId Int? @unique @default(1) diff --git a/services/app-api/src/domain-models/SettingType.tsx b/services/app-api/src/domain-models/SettingType.tsx new file mode 100644 index 0000000000..d2cfcfee78 --- /dev/null +++ b/services/app-api/src/domain-models/SettingType.tsx @@ -0,0 +1,23 @@ +import { z } from 'zod' + +const emailSettingsSchema = z.object({ + emailSource: z.string().email(), + devReviewTeamEmails: z.array(z.string().email()), + cmsReviewHelpEmailAddress: z.array(z.string().email()), + cmsRateHelpEmailAddress: z.array(z.string().email()), + oactEmails: z.array(z.string().email()), + dmcpReviewEmails: z.array(z.string().email()), + dmcpSubmissionEmails: z.array(z.string().email()), + dmcoEmails: z.array(z.string().email()), + helpDeskEmail: z.array(z.string().email()), +}) + +const applicationSettingsSchema = z.object({ + emailSettings: emailSettingsSchema, +}) + +type EmailSettingsType = z.infer +type ApplicationSettingsType = z.infer + +export type { EmailSettingsType, ApplicationSettingsType } +export { applicationSettingsSchema, emailSettingsSchema } \ No newline at end of file diff --git a/services/app-api/src/domain-models/index.ts b/services/app-api/src/domain-models/index.ts index c8a10c5b81..07dfd028ec 100644 --- a/services/app-api/src/domain-models/index.ts +++ b/services/app-api/src/domain-models/index.ts @@ -86,3 +86,6 @@ export type { APIKeyType } from './apiKey' export type { AuditDocument } from './DocumentType' export { auditDocumentSchema } from './DocumentType' + +export type { EmailSettingsType, ApplicationSettingsType } from './SettingType' +export { emailSettingsSchema, applicationSettingsSchema } from './SettingType' diff --git a/services/app-api/src/handlers/apollo_gql.ts b/services/app-api/src/handlers/apollo_gql.ts index 914ea02b0c..14ecee4b48 100644 --- a/services/app-api/src/handlers/apollo_gql.ts +++ b/services/app-api/src/handlers/apollo_gql.ts @@ -15,10 +15,9 @@ import { userFromLocalAuthProvider, userFromThirdPartyAuthorizer, } from '../authn' -import { newLocalEmailer, newSESEmailer } from '../emailer' import { NewPostgresStore } from '../postgres/postgresStore' import { configureResolvers } from '../resolvers' -import { configurePostgres } from './configuration' +import { configurePostgres, configureEmailer } from './configuration' import { createTracer } from '../otel/otel_handler' import { newAWSEmailParameterStore, @@ -233,63 +232,6 @@ async function initializeGQLHandler(): Promise { const store = NewPostgresStore(pgResult) - //Configure email parameter store. - const emailParameterStore = - parameterStoreMode === 'LOCAL' - ? newLocalEmailParameterStore() - : newAWSEmailParameterStore() - - // Configuring emails using emailParameterStore - // Moving setting these emails down here. We needed to retrieve all emails from parameter store using our - // emailParameterStore because serverless does not like array of strings as env variables. - // For more context see this ticket https://qmacbis.atlassian.net/browse/MR-2539. - const emailSource = await emailParameterStore.getSourceEmail() - const devReviewTeamEmails = - await emailParameterStore.getDevReviewTeamEmails() - const helpDeskEmail = await emailParameterStore.getHelpDeskEmail() - const cmsReviewHelpEmailAddress = - await emailParameterStore.getCmsReviewHelpEmail() - const cmsRateHelpEmailAddress = - await emailParameterStore.getCmsRateHelpEmail() - const oactEmails = await emailParameterStore.getOACTEmails() - const dmcpReviewEmails = await emailParameterStore.getDMCPReviewEmails() - const dmcpSubmissionEmails = - await emailParameterStore.getDMCPSubmissionEmails() - const dmcoEmails = await emailParameterStore.getDMCOEmails() - - if (emailSource instanceof Error) - throw new Error(`Configuration Error: ${emailSource.message}`) - - if (devReviewTeamEmails instanceof Error) - throw new Error(`Configuration Error: ${devReviewTeamEmails.message}`) - - if (helpDeskEmail instanceof Error) - throw new Error(`Configuration Error: ${helpDeskEmail.message}`) - - if (cmsReviewHelpEmailAddress instanceof Error) { - throw new Error( - `Configuration Error: ${cmsReviewHelpEmailAddress.message}` - ) - } - - if (cmsRateHelpEmailAddress instanceof Error) { - throw new Error( - `Configuration Error: ${cmsRateHelpEmailAddress.message}` - ) - } - - if (oactEmails instanceof Error) - throw new Error(`Configuration Error: ${oactEmails.message}`) - - if (dmcpReviewEmails instanceof Error) - throw new Error(`Configuration Error: ${dmcpReviewEmails.message}`) - - if (dmcpSubmissionEmails instanceof Error) - throw new Error(`Configuration Error: ${dmcpSubmissionEmails.message}`) - - if (dmcoEmails instanceof Error) - throw new Error(`Configuration Error: ${dmcoEmails.message}`) - // Configure LaunchDarkly const ldOptions: ld.LDOptions = { streamUri: 'https://stream.launchdarkly.us', @@ -328,46 +270,27 @@ async function initializeGQLHandler(): Promise { expirationDurationS: 90 * 24 * 60 * 60, // 90 days }) - // Print out all the variables we've been configured with. Leave sensitive ones out, please. - console.info('Running With Config: ', { - authMode, + //Configure email parameter store. + const emailParameterStore = + parameterStoreMode === 'LOCAL' + ? newLocalEmailParameterStore() + : newAWSEmailParameterStore() + + const emailer = await configureEmailer({ + emailParameterStore, + store, + ldService: launchDarkly, stageName, - dbURL, - applicationEndpoint, - emailSource, emailerMode, - otelCollectorUrl, - parameterStoreMode, + applicationEndpoint, }) - const emailer = - emailerMode == 'LOCAL' - ? newLocalEmailer({ - emailSource, - stage: 'local', - baseUrl: applicationEndpoint, - devReviewTeamEmails, - cmsReviewHelpEmailAddress, - cmsRateHelpEmailAddress, - oactEmails, - dmcpReviewEmails, - dmcpSubmissionEmails, - dmcoEmails, - helpDeskEmail, - }) - : newSESEmailer({ - emailSource, - stage: stageName, - baseUrl: applicationEndpoint, - devReviewTeamEmails, - cmsReviewHelpEmailAddress, - cmsRateHelpEmailAddress, - oactEmails, - dmcpReviewEmails, - dmcpSubmissionEmails, - dmcoEmails, - helpDeskEmail, - }) + if (emailer instanceof Error) { + const error = `Email Configuration error: ${emailer.message}` + console.error(error) + throw emailer + } + const S3_BUCKETS_CONFIG: S3BucketConfigType = { HEALTH_PLAN_DOCS: s3DocumentsBucket, QUESTION_ANSWER_DOCS: s3QABucket, @@ -380,6 +303,18 @@ async function initializeGQLHandler(): Promise { s3Client = newDeployedS3Client(S3_BUCKETS_CONFIG, region) } + // Print out all the variables we've been configured with. Leave sensitive ones out, please. + console.info('Running With Config: ', { + authMode, + stageName, + dbURL, + applicationEndpoint, + emailSource: emailer.config.emailSource, + emailerMode, + otelCollectorUrl, + parameterStoreMode, + }) + // Resolvers are defined and tested in the resolvers package const resolvers = configureResolvers( store, diff --git a/services/app-api/src/handlers/configuration.ts b/services/app-api/src/handlers/configuration.ts index 2543277576..0bdbe07be1 100644 --- a/services/app-api/src/handlers/configuration.ts +++ b/services/app-api/src/handlers/configuration.ts @@ -1,6 +1,14 @@ import type { PrismaClient } from '@prisma/client' -import { NewPrismaClient } from '../postgres' +import { NewPrismaClient, type Store } from '../postgres' import { FetchSecrets, getConnectionURL } from '../secrets' +import { type EmailParameterStore } from '../parameterStore' +import { + type EmailConfiguration, + type Emailer, + newLocalEmailer, + newSESEmailer, +} from '../emailer' +import { type LDService } from '../launchDarkly/launchDarkly' /* * configuration.ts @@ -82,4 +90,124 @@ async function getDBClusterID(secretName: string): Promise { return dbID } -export { configurePostgres, getPostgresURL, getDBClusterID } +async function configureEmailerFromDatabase( + store: Store +): Promise | Error> { + const emailSettings = await store.findEmailSettings() + if (emailSettings instanceof Error) { + return emailSettings + } + + return { + emailSource: emailSettings.emailSource, + devReviewTeamEmails: emailSettings.devReviewTeamEmails, + helpDeskEmail: emailSettings.helpDeskEmail[0], + cmsReviewHelpEmailAddress: emailSettings.cmsReviewHelpEmailAddress[0], + cmsRateHelpEmailAddress: emailSettings.cmsRateHelpEmailAddress[0], + oactEmails: emailSettings.oactEmails, + dmcpReviewEmails: emailSettings.dmcpReviewEmails, + dmcpSubmissionEmails: emailSettings.dmcpSubmissionEmails, + dmcoEmails: emailSettings.dmcoEmails, + } +} + +async function configureEmailerFromParamStore( + emailParameterStore: EmailParameterStore +): Promise | Error> { + // Configuring emails using emailParameterStore + // Moving setting these emails down here. We needed to retrieve all emails from parameter store using our + // emailParameterStore because serverless does not like array of strings as env variables. + // For more context see this ticket https://qmacbis.atlassian.net/browse/MR-2539. + const emailSource = await emailParameterStore.getSourceEmail() + const devReviewTeamEmails = + await emailParameterStore.getDevReviewTeamEmails() + const helpDeskEmail = await emailParameterStore.getHelpDeskEmail() + const cmsReviewHelpEmailAddress = + await emailParameterStore.getCmsReviewHelpEmail() + const cmsRateHelpEmailAddress = + await emailParameterStore.getCmsRateHelpEmail() + const oactEmails = await emailParameterStore.getOACTEmails() + const dmcpReviewEmails = await emailParameterStore.getDMCPReviewEmails() + const dmcpSubmissionEmails = + await emailParameterStore.getDMCPSubmissionEmails() + const dmcoEmails = await emailParameterStore.getDMCOEmails() + + if (emailSource instanceof Error) return new Error(emailSource.message) + + if (devReviewTeamEmails instanceof Error) + return new Error(devReviewTeamEmails.message) + + if (helpDeskEmail instanceof Error) return new Error(helpDeskEmail.message) + + if (cmsReviewHelpEmailAddress instanceof Error) + return new Error(cmsReviewHelpEmailAddress.message) + + if (cmsRateHelpEmailAddress instanceof Error) + return new Error(cmsRateHelpEmailAddress.message) + + if (oactEmails instanceof Error) return new Error(oactEmails.message) + + if (dmcpReviewEmails instanceof Error) + return new Error(dmcpReviewEmails.message) + + if (dmcpSubmissionEmails instanceof Error) + return new Error(dmcpSubmissionEmails.message) + + if (dmcoEmails instanceof Error) return new Error(dmcoEmails.message) + + return { + emailSource, + devReviewTeamEmails, + helpDeskEmail, + cmsReviewHelpEmailAddress, + cmsRateHelpEmailAddress, + oactEmails, + dmcpReviewEmails, + dmcpSubmissionEmails, + dmcoEmails, + } +} + +// Note: Email settings won't update dynamically if the database changes while this lambda is running. +// Consider configuring the emailer later to fetch settings on demand. +async function configureEmailer({ + emailParameterStore, + store, + ldService, + stageName, + emailerMode, + applicationEndpoint, +}: { + emailParameterStore: EmailParameterStore + store: Store + ldService: LDService + stageName: string + emailerMode: string + applicationEndpoint: string +}): Promise { + const removeParameterStore = await ldService.getFeatureFlag({ + key: 'email-configuration', + flag: 'remove-parameter-store', + }) + const emailSettings = removeParameterStore + ? await configureEmailerFromDatabase(store) + : await configureEmailerFromParamStore(emailParameterStore) + + if (emailSettings instanceof Error) { + return emailSettings + } + + return emailerMode == 'LOCAL' + ? newLocalEmailer({ + stage: 'local', + baseUrl: applicationEndpoint, + ...emailSettings, + }) + : newSESEmailer({ + stage: stageName, + baseUrl: applicationEndpoint, + ...emailSettings, + }) +} + +export { configurePostgres, getPostgresURL, getDBClusterID, configureEmailer } diff --git a/services/app-api/src/launchDarkly/launchDarkly.ts b/services/app-api/src/launchDarkly/launchDarkly.ts index d155a813d5..e5eb7efdc5 100644 --- a/services/app-api/src/launchDarkly/launchDarkly.ts +++ b/services/app-api/src/launchDarkly/launchDarkly.ts @@ -5,9 +5,9 @@ import type { } from '@mc-review/common-code' import { featureFlagKeys, featureFlags } from '@mc-review/common-code' import type { LDClient } from '@launchdarkly/node-server-sdk' -import type { Context } from '../handlers/apollo_gql' import { logError } from '../logger' import { setErrorAttributesOnActiveSpan } from '../resolvers/attributeHelper' +import type { Span } from '@opentelemetry/api' //Set up default feature flag values used to returned data const defaultFeatureFlags = (): FeatureFlagSettings => @@ -17,27 +17,33 @@ const defaultFeatureFlags = (): FeatureFlagSettings => return Object.assign(a, { [flag]: defaultValue }) }, {} as FeatureFlagSettings) +type LDServiceArgType = { + key: string + flag: FeatureFlagLDConstant + kind?: string + span?: Span +} + type LDService = { - getFeatureFlag: ( - context: Context, - flag: FeatureFlagLDConstant - ) => Promise - allFlags: (context: Context) => Promise + getFeatureFlag: (args: LDServiceArgType) => Promise + allFlags: ( + args: Omit + ) => Promise } function ldService(ldClient: LDClient): LDService { return { - getFeatureFlag: async (context, flag) => { + getFeatureFlag: async (args) => { const ldContext = { - kind: 'user', - key: context.user.email, + kind: args.kind ?? 'user', + key: args.key, } - return await ldClient.variation(flag, ldContext, false) + return await ldClient.variation(args.flag, ldContext, false) }, - allFlags: async (context) => { + allFlags: async (args) => { const ldContext = { - kind: 'user', - key: context.user.email, + kind: args.kind ?? 'user', + key: args.key, } const state = await ldClient.allFlagsState(ldContext) return state.allValues() @@ -47,26 +53,26 @@ function ldService(ldClient: LDClient): LDService { function offlineLDService(): LDService { return { - getFeatureFlag: async (context, flag) => { + getFeatureFlag: async (args) => { logError( 'getFeatureFlag', - `No connection to LaunchDarkly, fallback to offlineLDService with default value for ${flag}` + `No connection to LaunchDarkly, fallback to offlineLDService with default value for ${args.flag}` ) setErrorAttributesOnActiveSpan( - `No connection to LaunchDarkly, fallback to offlineLDService with default value for ${flag}`, - context.span + `No connection to LaunchDarkly, fallback to offlineLDService with default value for ${args.flag}`, + args.span ) const featureFlags = defaultFeatureFlags() - return featureFlags[flag] + return featureFlags[args.flag] }, - allFlags: async (context) => { + allFlags: async (args) => { logError( 'allFlags', `No connection to LaunchDarkly, fallback to offlineLDService with default values` ) setErrorAttributesOnActiveSpan( `No connection to LaunchDarkly, fallback to offlineLDService with default values`, - context.span + args.span ) return defaultFeatureFlags() }, diff --git a/services/app-api/src/postgres/postgresStore.ts b/services/app-api/src/postgres/postgresStore.ts index 7fe5ba348a..05f94330a7 100644 --- a/services/app-api/src/postgres/postgresStore.ts +++ b/services/app-api/src/postgres/postgresStore.ts @@ -20,6 +20,7 @@ import type { RateQuestionType, CreateRateQuestionInputType, AuditDocument, + EmailSettingsType, } from '../domain-models' import { findPrograms, findStatePrograms } from '../postgres' import type { InsertUserArgsType } from './user' @@ -80,6 +81,7 @@ import { findStateAssignedUsers } from './state/findStateAssignedUsers' import { findAllDocuments } from './documents' import type { WithdrawRateArgsType } from './contractAndRates/withdrawRate' import { withdrawRate } from './contractAndRates/withdrawRate' +import { findEmailSettings } from './settings/findEmailSettings' type Store = { findPrograms: ( @@ -215,6 +217,8 @@ type Store = { findRateRevision: ( rateRevisionID: string ) => Promise + + findEmailSettings: () => Promise } function NewPostgresStore(client: PrismaClient): Store { @@ -295,6 +299,8 @@ function NewPostgresStore(client: PrismaClient): Store { findAllDocuments: () => findAllDocuments(client), findContractRevision: (args) => findContractRevision(client, args), findRateRevision: (args) => findRateRevision(client, args), + + findEmailSettings: () => findEmailSettings(client), } } diff --git a/services/app-api/src/postgres/settings/findEmailSettings.ts b/services/app-api/src/postgres/settings/findEmailSettings.ts new file mode 100644 index 0000000000..3458b2ff9c --- /dev/null +++ b/services/app-api/src/postgres/settings/findEmailSettings.ts @@ -0,0 +1,34 @@ +import type { PrismaClient } from '@prisma/client' +import type { EmailSettingsType } from '../../domain-models' + +export async function findEmailSettings( + client: PrismaClient +): Promise { + try { + const result = await client.emailSettings.findUnique({ + where: { + id: 1, //There is only one row in the email_settings table + }, + select: { + emailSource: true, + devReviewTeamEmails: true, + cmsReviewHelpEmailAddress: true, + cmsRateHelpEmailAddress: true, + oactEmails: true, + dmcpReviewEmails: true, + dmcpSubmissionEmails: true, + dmcoEmails: true, + helpDeskEmail: true, + }, // excludes the applicationSettingsId field + }) + + if (!result) { + return new Error('Prisma Error: Email settings not found') + } + + return result + } catch (err) { + console.error('PRISMA ERROR: Error withdrawing rate', err) + return err + } +} diff --git a/services/app-api/src/resolvers/contract/submitContract.test.ts b/services/app-api/src/resolvers/contract/submitContract.test.ts index b66772426b..be39386ad0 100644 --- a/services/app-api/src/resolvers/contract/submitContract.test.ts +++ b/services/app-api/src/resolvers/contract/submitContract.test.ts @@ -38,7 +38,7 @@ import { import { testLDService } from '../../testHelpers/launchDarklyHelpers' import { latestFormData } from '../../testHelpers/healthPlanPackageHelpers' import { sharedTestPrismaClient } from '../../testHelpers/storeHelpers' -import { testEmailConfig, testEmailer } from '../../testHelpers/emailerHelpers' +import { testEmailConfig, testEmailer, testEmailerFromDatabase } from '../../testHelpers/emailerHelpers' import { NewPostgresStore } from '../../postgres' import { dayjs } from '@mc-review/dates' @@ -1511,6 +1511,45 @@ describe('submitContract', () => { expect(mockEmailer.sendEmail).not.toHaveBeenCalled() }) + it('uses email settings from database with remove-parameter-store flag on', async () => { + const mockEmailer = await testEmailerFromDatabase() + const ldService = testLDService( + { + 'remove-parameter-store': true + } + ) + + const stateServer = await constructTestPostgresServer({ + context: { + user: testStateUser(), + }, + ldService, + emailer: mockEmailer, + }) + + const submitResult = await createAndSubmitTestContractWithRate(stateServer) + + const currentRevision = + submitResult.packageSubmissions[0].contractRevision + + const name = currentRevision.contractName + + expect(mockEmailer.sendEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + subject: expect.stringContaining(`New Managed Care Submission: ${name}`), + sourceEmail: 'mc-review@cms.hhs.gov', + toAddresses: expect.arrayContaining( + Array.from([ + 'mc-review-qa+DevTeam@truss.works', + 'mc-review-qa+DMCPsubmissiondev1@truss.works', + 'mc-review-qa+DMCPsubmissiondev2@truss.works' + ]) + ), + }) + ) + }) + // TODO: reimplement this test without using jest // it('errors when SES email has failed.', async () => { // const mockEmailer = testEmailer() diff --git a/services/app-api/src/resolvers/contract/submitContract.ts b/services/app-api/src/resolvers/contract/submitContract.ts index 6b107fbc5c..f793afaaee 100644 --- a/services/app-api/src/resolvers/contract/submitContract.ts +++ b/services/app-api/src/resolvers/contract/submitContract.ts @@ -58,7 +58,9 @@ export function submitContract( launchDarkly: LDService ): MutationResolvers['submitContract'] { return async (_parent, { input }, context) => { - const featureFlags = await launchDarkly.allFlags(context) + const featureFlags = await launchDarkly.allFlags({ + key: context.user.email, + }) const { user, ctx, tracer } = context const span = tracer?.startSpan('submitContract', {}, ctx) @@ -192,13 +194,13 @@ export function submitContract( } // add all rates (including any linked rates) back in parsedContract.draftRates = contractWithHistory.draftRates - const parsedSubmissionType = parsedContract.draftRevision?.formData.submissionType + const parsedSubmissionType = + parsedContract.draftRevision?.formData.submissionType // If this contract is being submitted as CONTRACT_ONLY but still has associations with rates // we need to prune those rates at submission time to make the submission clean if ( - parsedSubmissionType === - 'CONTRACT_ONLY' && + parsedSubmissionType === 'CONTRACT_ONLY' && parsedContract.draftRates && parsedContract.draftRates.length > 0 ) { @@ -246,20 +248,15 @@ export function submitContract( throw new Error(errMessage) } } - + // If this is contract and rates lets verify that the rates are in a valid state if ( - parsedSubmissionType === - 'CONTRACT_AND_RATES' && + parsedSubmissionType === 'CONTRACT_AND_RATES' && parsedContract.draftRates && parsedContract.draftRates.length > 0 ) { for (const draftRate of parsedContract.draftRates) { - if ( - ['WITHDRAWN'].includes( - draftRate.consolidatedStatus - ) - ) { + if (['WITHDRAWN'].includes(draftRate.consolidatedStatus)) { const errMessage = `Attempted to submit a rate withdrawn rate: ${draftRate.consolidatedStatus}` logError('submitContract', errMessage) setErrorAttributesOnActiveSpan(errMessage, span) diff --git a/services/app-api/src/resolvers/contract/updateContractDraftRevision.ts b/services/app-api/src/resolvers/contract/updateContractDraftRevision.ts index 2ad61f7f28..2c25903e88 100644 --- a/services/app-api/src/resolvers/contract/updateContractDraftRevision.ts +++ b/services/app-api/src/resolvers/contract/updateContractDraftRevision.ts @@ -25,7 +25,9 @@ export function updateContractDraftRevision( user, span ) - const featureFlags = await launchDarkly.allFlags(context) + const featureFlags = await launchDarkly.allFlags({ + key: context.user.email, + }) const { formData, contractID, lastSeenUpdatedAt } = input const contractWithHistory = diff --git a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts index 377119268b..a72d251b9b 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/submitHealthPlanPackage.ts @@ -197,7 +197,9 @@ export function submitHealthPlanPackageResolver( launchDarkly: LDService ): MutationResolvers['submitHealthPlanPackage'] { return async (_parent, { input }, context) => { - const featureFlags = await launchDarkly.allFlags(context) + const featureFlags = await launchDarkly.allFlags({ + key: context.user.email, + }) const { user, ctx, tracer } = context const span = tracer?.startSpan('submitHealthPlanPackage', {}, ctx) diff --git a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts index 0c7430e7ce..5d4940963d 100644 --- a/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts +++ b/services/app-api/src/resolvers/healthPlanPackage/updateHealthPlanFormData.ts @@ -27,7 +27,9 @@ export function updateHealthPlanFormDataResolver( const span = tracer?.startSpan('updateHealthPlanFormData', {}, ctx) setResolverDetailsOnActiveSpan('updateHealthPlanFormData', user, span) - const featureFlags = await launchDarkly.allFlags(context) + const featureFlags = await launchDarkly.allFlags({ + key: context.user.email, + }) // This resolver is only callable by state users if (!isStateUser(context.user)) { diff --git a/services/app-api/src/resolvers/rate/submitRate.ts b/services/app-api/src/resolvers/rate/submitRate.ts index 53b53bad81..d055e8baaf 100644 --- a/services/app-api/src/resolvers/rate/submitRate.ts +++ b/services/app-api/src/resolvers/rate/submitRate.ts @@ -28,7 +28,9 @@ export function submitRate( setResolverDetailsOnActiveSpan('submitRate', user, span) const { rateID, submittedReason, formData } = input - const featureFlags = await launchDarkly.allFlags(context) + const featureFlags = await launchDarkly.allFlags({ + key: context.user.email, + }) span?.setAttribute('mcreview.rate_id', rateID) diff --git a/services/app-api/src/resolvers/settings/fetchMcReviewSettings.test.ts b/services/app-api/src/resolvers/settings/fetchMcReviewSettings.test.ts index fded350a67..4663155ac8 100644 --- a/services/app-api/src/resolvers/settings/fetchMcReviewSettings.test.ts +++ b/services/app-api/src/resolvers/settings/fetchMcReviewSettings.test.ts @@ -12,6 +12,7 @@ import { UpdateStateAssignmentDocument, } from '../../gen/gqlClient' import { assertAnError, must } from '../../testHelpers' +import { testLDService } from '../../testHelpers/launchDarklyHelpers' describe('fetchMcReviewSettings', () => { it('returns states with assignments', async () => { @@ -202,4 +203,41 @@ describe('fetchMcReviewSettings', () => { 'user not authorized to fetch mc review settings' ) }) + + it('uses email settings from database with remove-parameter-store flag on', async () => { + const prismaClient = await sharedTestPrismaClient() + const postgresStore = NewPostgresStore(prismaClient) + + const server = await constructTestPostgresServer({ + context: { + user: testAdminUser(), + }, + ldService: testLDService( + { + 'remove-parameter-store': true + } + ) + }) + + const emailSettings = must(await postgresStore.findEmailSettings()) + + const mcReviewSettings = must(await server.executeOperation({ + query: FetchMcReviewSettingsDocument, + })) + + const emailConfig = mcReviewSettings.data?.fetchMcReviewSettings.emailConfiguration + + // Expect the default email settings from database + expect(emailConfig.emailSource).toEqual(emailSettings.emailSource) + expect(emailConfig.devReviewTeamEmails).toEqual(emailSettings.devReviewTeamEmails) + expect(emailConfig.oactEmails).toEqual(emailSettings.oactEmails) + expect(emailConfig.dmcpReviewEmails).toEqual(emailSettings.dmcpReviewEmails) + expect(emailConfig.dmcpSubmissionEmails).toEqual(emailSettings.dmcpSubmissionEmails) + expect(emailConfig.dmcoEmails).toEqual(emailSettings.dmcoEmails) + + //These emails are arrays in the DB, but single strings in EmailConfiguration type. + expect(emailConfig.cmsReviewHelpEmailAddress).toEqual(emailSettings.cmsReviewHelpEmailAddress[0]) + expect(emailConfig.cmsRateHelpEmailAddress).toEqual(emailSettings.cmsRateHelpEmailAddress[0]) + expect(emailConfig.helpDeskEmail).toEqual(emailSettings.helpDeskEmail[0]) + }) }) diff --git a/services/app-api/src/testHelpers/emailerHelpers.ts b/services/app-api/src/testHelpers/emailerHelpers.ts index fa2e9bb17e..b52104c442 100644 --- a/services/app-api/src/testHelpers/emailerHelpers.ts +++ b/services/app-api/src/testHelpers/emailerHelpers.ts @@ -20,6 +20,7 @@ import { SESServiceException } from '@aws-sdk/client-ses' import { testSendSESEmail } from './awsSESHelpers' import { testCMSUser, testStateUser } from './userHelpers' import { v4 as uuidv4 } from 'uuid' +import { constructTestEmailer } from './gqlHelpers' const testEmailConfig = (): EmailConfiguration => ({ stage: 'LOCAL', @@ -84,6 +85,15 @@ function testEmailer(customConfig?: EmailConfiguration): Emailer { return emailer(config, vi.fn(sendTestEmails)) } +async function testEmailerFromDatabase(customConfig?: EmailConfiguration): Promise { + const mockEmailer = await constructTestEmailer({ + emailConfig: customConfig, + }) + mockEmailer.sendEmail = vi.fn() + + return mockEmailer +} + type State = { name: string programs: ProgramArgType[] @@ -998,4 +1008,5 @@ export { mockQuestionAndResponses, mockRateQuestionAndResponses, mockRate, + testEmailerFromDatabase } diff --git a/services/app-api/src/testHelpers/gqlHelpers.ts b/services/app-api/src/testHelpers/gqlHelpers.ts index 8e3dc3534a..b762da60a9 100644 --- a/services/app-api/src/testHelpers/gqlHelpers.ts +++ b/services/app-api/src/testHelpers/gqlHelpers.ts @@ -22,9 +22,9 @@ import type { InsertQuestionResponseArgs, ProgramType, CreateRateQuestionInputType, + EmailSettingsType, } from '../domain-models' -import type { Emailer } from '../emailer' -import { newLocalEmailer } from '../emailer' +import type { EmailConfiguration, Emailer } from '../emailer' import type { CreateHealthPlanPackageInput, HealthPlanPackage, @@ -39,7 +39,10 @@ import { configureResolvers } from '../resolvers' import { latestFormData } from './healthPlanPackageHelpers' import { sharedTestPrismaClient } from './storeHelpers' import { domainToBase64 } from '@mc-review/hpp' -import type { EmailParameterStore } from '../parameterStore' +import { + newLocalEmailParameterStore, + type EmailParameterStore, +} from '../parameterStore' import { testLDService } from './launchDarklyHelpers' import type { LDService } from '../launchDarkly/launchDarkly' import { insertUserToLocalAurora } from '../authn' @@ -54,6 +57,7 @@ import { convertRateInfoToRateFormDataInput } from '../domain-models/contractAnd import { createAndUpdateTestContractWithoutRates } from './gqlContractHelpers' import { addNewRateToTestContract } from './gqlRateHelpers' import type { GraphQLResponse } from 'apollo-server-types' +import { configureEmailer } from '../handlers/configuration' // Since our programs are checked into source code, we have a program we // use as our default @@ -92,7 +96,6 @@ const constructTestPostgresServer = async (opts?: { }): Promise => { // set defaults const context = opts?.context || defaultContext() - const emailer = opts?.emailer || constructTestEmailer() const ldService = opts?.ldService || testLDService() const prismaClient = await sharedTestPrismaClient() @@ -112,6 +115,16 @@ const constructTestPostgresServer = async (opts?: { const s3TestClient = testS3Client() const s3 = opts?.s3Client || s3TestClient + const emailer = + opts?.emailer ?? + (await constructTestEmailer({ + ldService, + })) + + if (emailer instanceof Error) { + throw new Error(`Failed to configure emailer: ${emailer.message}`) + } + const postgresResolvers = configureResolvers( postgresStore, emailer, @@ -128,21 +141,63 @@ const constructTestPostgresServer = async (opts?: { }) } -const constructTestEmailer = (): Emailer => { - const config = { - emailSource: 'local@example.com', - stage: 'localtest', - baseUrl: 'http://localtest', - devReviewTeamEmails: ['test@example.com'], - cmsReviewHelpEmailAddress: 'mcog@example.com', - cmsRateHelpEmailAddress: 'rates@example.com', - oactEmails: ['testRate@example.com'], - dmcpReviewEmails: ['testPolicy@example.com'], - dmcpSubmissionEmails: ['testPolicySubmission@example.com'], - dmcoEmails: ['testDmco@example.com'], - helpDeskEmail: 'MC_Review_HelpDesk@example.com>', +const constructTestEmailer = async (opts?: { + emailConfig?: EmailConfiguration + postgresStore?: Store + ldService?: LDService +}): Promise => { + const { + emailConfig, + postgresStore, + ldService = testLDService({ 'remove-parameter-store': true }), // default to using email settings from database + } = opts ?? {} + + let store = postgresStore + if (!store) { + const prismaClient = await sharedTestPrismaClient() + store = NewPostgresStore(prismaClient) } - return newLocalEmailer(config) + + const emailSettings = must(await store.findEmailSettings()) + + const testEmailSettings: EmailSettingsType = { + emailSource: emailConfig?.emailSource ?? emailSettings.emailSource, + devReviewTeamEmails: + emailConfig?.devReviewTeamEmails ?? + emailSettings.devReviewTeamEmails, + oactEmails: emailConfig?.oactEmails ?? emailSettings.oactEmails, + dmcpReviewEmails: + emailConfig?.dmcpReviewEmails ?? emailSettings.dmcpReviewEmails, + dmcpSubmissionEmails: + emailConfig?.dmcpSubmissionEmails ?? + emailSettings.dmcpSubmissionEmails, + dmcoEmails: emailConfig?.dmcoEmails ?? emailSettings.dmcoEmails, + // These three settings are string[] in db but string in EmailConfiguration, follow up ticket will convert EmailConfiguration to string[] + cmsReviewHelpEmailAddress: emailConfig?.cmsReviewHelpEmailAddress + ? [emailConfig?.cmsReviewHelpEmailAddress] + : emailSettings.cmsReviewHelpEmailAddress, + cmsRateHelpEmailAddress: emailConfig?.cmsRateHelpEmailAddress + ? [emailConfig?.cmsRateHelpEmailAddress] + : emailSettings.cmsRateHelpEmailAddress, + helpDeskEmail: emailConfig?.helpDeskEmail + ? [emailConfig?.helpDeskEmail] + : emailSettings.helpDeskEmail, + } + + store.findEmailSettings = async () => { + return testEmailSettings + } + + return must( + await configureEmailer({ + emailParameterStore: newLocalEmailParameterStore(), + store, + ldService, + stageName: emailConfig?.stage ?? 'localtest', + emailerMode: 'LOCAL', + applicationEndpoint: emailConfig?.baseUrl ?? 'http://localtest', + }) + ) } const createTestHealthPlanPackage = async ( @@ -585,4 +640,5 @@ export { updateTestStateAssignments, createTestRateQuestion, createTestRateQuestionResponse, + constructTestEmailer, } diff --git a/services/app-api/src/testHelpers/launchDarklyHelpers.ts b/services/app-api/src/testHelpers/launchDarklyHelpers.ts index c31d6d941d..a807213241 100644 --- a/services/app-api/src/testHelpers/launchDarklyHelpers.ts +++ b/services/app-api/src/testHelpers/launchDarklyHelpers.ts @@ -20,8 +20,8 @@ function testLDService(mockFeatureFlags?: FeatureFlagSettings): LDService { } return { - getFeatureFlag: async (user, flag) => featureFlags[flag], - allFlags: async (user) => featureFlags, + getFeatureFlag: async ({ flag }) => featureFlags[flag], + allFlags: async () => featureFlags, } } diff --git a/services/app-api/src/testHelpers/storeHelpers.ts b/services/app-api/src/testHelpers/storeHelpers.ts index a2a0943cf3..f8b1418c15 100644 --- a/services/app-api/src/testHelpers/storeHelpers.ts +++ b/services/app-api/src/testHelpers/storeHelpers.ts @@ -151,6 +151,9 @@ function mockStoreThatErrors(): Store { withdrawRate: async (_ID) => { return genericError }, + findEmailSettings: async () => { + return genericError + }, } }