diff --git a/.github/build_vars.sh b/.github/build_vars.sh index 762013f29..0aeb6f0de 100755 --- a/.github/build_vars.sh +++ b/.github/build_vars.sh @@ -15,6 +15,7 @@ var_list=( COGNITO_TEST_USERS_PASSWORD NO_EMAIL_DEBUG REACT_APP_GOOGLE_TAG + REACT_APP_LD_CLIENT_ID ) set_value() { diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 46b446a8e..fecc339bd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -59,6 +59,8 @@ jobs: name: ${{ startsWith(github.ref_name, 'snyk-') && 'snyk' || github.ref_name }} url: "https://onemac.cms.gov" steps: + - name: Check GITHUB_REF + run: echo "GITHUB_REF is $GITHUB_REF" - name: set branch_name run: echo "branch_name=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV - name: Check branch name is a legal serverless stage name @@ -110,6 +112,11 @@ jobs: with: role-to-assume: ${{ env.AWS_OIDC_ROLE_TO_ASSUME }} aws-region: ${{ env.AWS_DEFAULT_REGION }} + - name: Fetch SSM Parameter + id: fetch_ssm + run: | + LdClientId=$(aws ssm get-parameter --name "/configuration/onemacmmdl/LdClientId" --query "Parameter.Value" --output text) + echo "REACT_APP_LD_CLIENT_ID=${LdClientId}" >> $GITHUB_ENV - uses: actions/setup-node@v3 with: node-version: "20.x" @@ -312,7 +319,7 @@ jobs: Profile_View_CMS_User_Denied.spec.feature, Profile_View_CMS_User_Revoked.spec.feature, Profile_View_Helpdesk_User.spec.feature, - Profile_View_Mixed_Case_Emails.spec.feature, + Profile_View_Remove_Email_From_URL.spec.feature, Profile_View_State_Submitter.spec.feature, Profile_View_State_System_Admin.spec.feature, Request_A_Role_Change_As_CMS_Read_Only.spec.feature, diff --git a/services/admin/handlers/addRolesToJWT.js b/services/admin/handlers/addRolesToJWT.js new file mode 100644 index 000000000..e70ece2a3 --- /dev/null +++ b/services/admin/handlers/addRolesToJWT.js @@ -0,0 +1,25 @@ +import { getUser } from "../../app-api/getUser"; + +const handler = async (event) => { + console.log("JWT claims before modification:", JSON.stringify(event)); + try{ + const userEmail = event.request.userAttributes.email; + const user = await getUser(userEmail); + const roles = []; + for (const role of user.roleList) { + roles.push(role.role) + } + event.response = event.response || {}; + event.response.claimsOverrideDetails = event.response.claimsOverrideDetails || {}; + event.response.claimsOverrideDetails.claimsToAddOrOverride = event.response.claimsOverrideDetails.claimsToAddOrOverride || {}; + + // Example of adding roles dynamically from DynamoDB to the JWT claims + event.response.claimsOverrideDetails.claimsToAddOrOverride['custom:user_roles'] = JSON.stringify(roles); // Add user roles + } catch(e) { + console.log("error updating id token claims", e) + } + console.log("JWT claims after modification:", JSON.stringify(event)); + return event; +}; + +export { handler }; diff --git a/services/admin/handlers/batchUpdateCognitoUsers.js b/services/admin/handlers/batchUpdateCognitoUsers.js new file mode 100644 index 000000000..2f0a65652 --- /dev/null +++ b/services/admin/handlers/batchUpdateCognitoUsers.js @@ -0,0 +1,76 @@ +import AWS from "aws-sdk"; +const cognito = new AWS.CognitoIdentityServiceProvider(); +import { getUser } from "../../app-api/getUser"; + +async function updateUserAttribute(userPoolId, username, roles) { + const params = { + UserPoolId: userPoolId, + Username: username, + UserAttributes: [ + { + Name: 'custom:user_roles', + Value: JSON.stringify(roles) + } + ] + }; + + await cognito.adminUpdateUserAttributes(params).promise(); + } + +async function processCognitoUsers() { + const userPoolId = process.env.USER_POOL_ID; + console.log("user pool id: ", userPoolId) + let paginationToken = null; + let counter = 0; + let hasRolesCounter = 0; + let noRolesCounter =0; + do { + const params = { + UserPoolId: userPoolId, + AttributesToGet: ['email'], + PaginationToken: paginationToken + }; + + const listUsersResponse = await cognito.listUsers(params).promise(); + console.log(listUsersResponse.Users.length + " users found") + + for (const user of listUsersResponse.Users) { + const emailAttribute = user.Attributes.find(attr => attr.Name === 'email'); + if (emailAttribute) { + const userEmail = emailAttribute.Value; + + try { + const externalUser = await getUser(userEmail); + let roles = [""]; + let roleList; + try{ + roleList = externalUser.roleList; + }catch(error) { + noRolesCounter ++ + console.log(userEmail + " has no roles"); + } + if (roleList && roleList.length > 0 && roleList[0] != null) { + roles = externalUser.roleList.map(role => role.role); + hasRolesCounter ++; + } else { + console.log("user parsing error for user" + userEmail) + } + await updateUserAttribute(userPoolId, user.Username, roles); + } catch (error) { + console.error(`Error processing user ${userEmail}:`, error); + } + } + counter++; + } + + paginationToken = listUsersResponse.PaginationToken; + } while (paginationToken); + console.log(counter+ "users modified, "+ hasRolesCounter + "users had roles and "+ noRolesCounter + " users had no roles") +} + + +export const main = async () => { + await processCognitoUsers().catch(console.error); + console.log("function complete") +} + diff --git a/services/admin/handlers/insertNotification.json b/services/admin/handlers/insertNotification.json new file mode 100644 index 000000000..5360574c5 --- /dev/null +++ b/services/admin/handlers/insertNotification.json @@ -0,0 +1,10 @@ +{ + "_comment": "Dates are passed in format YYYY-MM-DD or keywords 'today' or 'x days from now'", + "publicationDate": "2024-01-01", + "expiryDate": "7 days from now", + "header": "New Feature Release", + "body": "We have released a new feature. Check it out now!", + "buttonText": "Learn More", + "buttonLink": "https://example.com/feature", + "notificationType": "user" +} diff --git a/services/admin/handlers/insertNotification.ts b/services/admin/handlers/insertNotification.ts new file mode 100644 index 000000000..c1afd6a4a --- /dev/null +++ b/services/admin/handlers/insertNotification.ts @@ -0,0 +1,150 @@ +import { DynamoDB } from "aws-sdk"; +import { v4 as uuidv4 } from "uuid"; // Import UUID library +import { addDays } from "date-fns"; // Import a date manipulation library like date-fns for handling date parsing + +const dynamoDb = new DynamoDB.DocumentClient( + process.env.IS_OFFLINE + ? { + endpoint: "http://localhost:8000", + } + : {} +); +const oneMacTableName = process.env.oneMacTableName || "defaultTableName"; + +// Define types for event and notification record +interface EventInput { + publicationDate?: string; + expiryDate?: string; + header: string; + body: string; + buttonText?: string; + buttonLink?: string; + notificationType: "user" | "system"; // User-friendly notification type input +} + +interface NotificationRecord { + pk: string; + sk: string; + GSI1pk: string; + GSI1sk: string; + publicationDate: string; + expiryDate: string; + header: string; + body: string; + buttonText?: string | null; + buttonLink?: string | null; + createdAt: string; +} + +// Function to translate notificationType to GSI1pk value +function mapNotificationType(type: "user" | "system"): string { + return type === "user" ? "USER_NOTIFICATION" : "SYSTEM"; +} + +// Function to handle flexible date inputs +function parseDate(input?: string): string { + // If no input is provided, default to the current date + if (!input) { + return new Date().toISOString(); + } + + if (input === "today") { + return new Date().toISOString(); + } + + if (input.includes("days from now")) { + const days = parseInt(input.split(" ")[0], 10); + return addDays(new Date(), days).toISOString(); + } + + // Assuming user passes YYYY-MM-DD format for easier input + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + return new Date(`${input}T00:00:00Z`).toISOString(); + } + + // Default to current date if input is invalid + return new Date().toISOString(); +} + +function validateEvent(event: EventInput): void { + const missingParams: string[] = []; + + if (!event.header) { + missingParams.push("header"); + } + if (!event.body) { + missingParams.push("body"); + } + if (!event.notificationType) { + missingParams.push("notificationType"); + } + if (missingParams.length > 0) { + throw new Error(`Missing event parameters: ${missingParams.join(", ")}`); + } + + console.log("Event passed validation"); +} + +function generateNotificationId(): string { + return uuidv4(); +} + +function formatNotificationRecord(event: EventInput): NotificationRecord { + const notificationId = generateNotificationId(); // Generate a UUID for the notification ID + const pk = "SYSTEM"; // System-wide notification + const sk = `NOTIFICATION#${notificationId}`; // Unique notification identifier + + // Default values for publicationDate and expiryDate with more user-friendly inputs + const publicationDate = parseDate(event.publicationDate); // Default to current date + const expiryDate = event.expiryDate + ? parseDate(event.expiryDate) + : "9999-12-31T23:59:59Z"; // Default to far future date + + // Use the translated notificationType + const GSI1pk = mapNotificationType(event.notificationType); + const GSI1sk = `${publicationDate}#${expiryDate}`; // Sort key for GSI based on dates + const createdAt = new Date().toISOString(); // Current timestamp + + return { + pk, + sk, + GSI1pk, + GSI1sk, + publicationDate, + expiryDate, + header: event.header, + body: event.body, + buttonText: event.buttonText || null, // Optional field + buttonLink: event.buttonLink || null, // Optional field + createdAt, + }; +} + +async function insertNotification(record: NotificationRecord): Promise { + const params: DynamoDB.DocumentClient.PutItemInput = { + TableName: oneMacTableName, + Item: record, + }; + + console.log("Inserting notification", params); + + await dynamoDb.put(params).promise(); +} + +export const main = async (event: EventInput) => { + console.log("insertNotification.main", event); + + validateEvent(event); + + const notificationRecord = formatNotificationRecord(event); + + await insertNotification(notificationRecord); + + return { + statusCode: 200, + body: JSON.stringify({ + message: "Notification inserted successfully", + record: notificationRecord, // Return the full inserted record + }), + }; +}; diff --git a/services/admin/serverless.yml b/services/admin/serverless.yml index fe425b7f4..a8cc4705d 100644 --- a/services/admin/serverless.yml +++ b/services/admin/serverless.yml @@ -4,6 +4,8 @@ frameworkVersion: "3" useDotenv: true +variablesResolutionMode: 20210326 + package: individually: true @@ -11,10 +13,14 @@ plugins: - serverless-esbuild - serverless-dotenv-plugin - serverless-s3-bucket-helper + custom: stage: ${opt:stage, self:provider.stage} iamPermissionsBoundaryPolicy: ${ssm:/configuration/${self:custom.stage}/iam/permissionsBoundaryPolicy, ssm:/configuration/default/iam/permissionsBoundaryPolicy, ""} oneMacTableName: onemac-${self:custom.stage}-one + userPoolName: ${self:custom.stage}-user-pool + userPoolId: ${cf:ui-auth-${self:custom.stage}.UserPoolId} + provider: name: aws runtime: nodejs20.x @@ -37,14 +43,29 @@ provider: - arn:aws:dynamodb:*:*:table/onemac-develop-one - arn:aws:dynamodb:*:*:table/${self:custom.oneMacTableName} - arn:aws:dynamodb:*:*:table/${self:custom.oneMacTableName}/index/* + - Effect: Allow + Action: + - cognito-idp:ListUsers + - cognito-idp:AdminUpdateUserAttributes + Resource: arn:aws:cognito-idp:${self:provider.region}:*:userpool/${self:custom.userPoolId} + environment: NODE_OPTIONS: '--enable-source-maps' oneMacTableName: ${self:custom.oneMacTableName} + USER_POOL_ID: ${self:custom.userPoolId} layers: - ${cf:aws-sdk-v2-layer-${self:custom.stage}.AwsSdkV2LambdaLayerQualifiedArn} + functions: - + batchUpdateCognitoUsers: + handler: ./handlers/batchUpdateCognitoUsers.main + timeout: 360 + + addRolesToJWT: + handler: ./handlers/addRolesToJWT.handler + timeout: 360 + resetData: handler: ./handlers/resetData.main timeout: 360 @@ -79,3 +100,7 @@ functions: - schedule: rate: rate(6 hours) timeout: 180 + + insertNotification: + handler: ./handlers/insertNotification.main + timeout: 180 diff --git a/services/app-api/getDetail.js b/services/app-api/getDetail.js index 054fa52c5..774d4a2bd 100644 --- a/services/app-api/getDetail.js +++ b/services/app-api/getDetail.js @@ -87,7 +87,7 @@ export const getDetails = async (event) => { if (!userRoleObj.isCMSUser && result.Item.reviewTeam) delete result.Item.reviewTeam; - result.Item.actions = getActionsForPackage( + result.Item.actions = await getActionsForPackage( result.Item.componentType, originalStatus, !!result.Item.latestRaiResponseTimestamp, diff --git a/services/app-api/getMyPackages.js b/services/app-api/getMyPackages.js index 91cb1f4ee..38dd8e607 100644 --- a/services/app-api/getMyPackages.js +++ b/services/app-api/getMyPackages.js @@ -19,106 +19,102 @@ export const getMyPackages = async (email, group) => { if (!email) return RESPONSE_CODE.USER_NOT_FOUND; if (!group) return RESPONSE_CODE.DATA_MISSING; - return getUser(email) - .then((user) => { - if (!user) throw RESPONSE_CODE.USER_NOT_AUTHORIZED; + try { + const user = await getUser(email); + if (!user) throw RESPONSE_CODE.USER_NOT_AUTHORIZED; - const userRoleObj = getUserRoleObj(user.roleList); - const territoryList = getActiveTerritories(user.roleList); - const statusMap = userRoleObj.isCMSUser - ? cmsStatusUIMap - : stateStatusUIMap; + const userRoleObj = getUserRoleObj(user.roleList); + const territoryList = getActiveTerritories(user.roleList); + const statusMap = userRoleObj.isCMSUser ? cmsStatusUIMap : stateStatusUIMap; - if ( - !userRoleObj.canAccessDashboard || - (Array.isArray(territoryList) && territoryList.length === 0) - ) { - throw RESPONSE_CODE.USER_NOT_AUTHORIZED; - } + if ( + !userRoleObj.canAccessDashboard || + (Array.isArray(territoryList) && territoryList.length === 0) + ) { + throw RESPONSE_CODE.USER_NOT_AUTHORIZED; + } - const baseParams = { - TableName: process.env.oneMacTableName, - IndexName: "GSI1", - ExclusiveStartKey: null, - ScanIndexForward: false, - ProjectionExpression: - "componentId,componentType,currentStatus,submissionTimestamp,latestRaiResponseTimestamp,lastActivityTimestamp,submitterName,submitterEmail,waiverAuthority, cpocName, reviewTeam, subStatus, finalDispositionDate", - }; - const grouppk = "OneMAC#" + group; - let paramList = []; - if (territoryList[0] !== "N/A") { - paramList = territoryList.map((territory) => { - return { - ...baseParams, - KeyConditionExpression: "GSI1pk = :pk AND begins_with(GSI1sk,:t1)", - ExpressionAttributeValues: { - ":pk": grouppk, - ":t1": territory, - }, - }; - }); - } else { - paramList = [ - { - ...baseParams, - KeyConditionExpression: "GSI1pk = :pk", - ExpressionAttributeValues: { - ":pk": grouppk, - }, + const baseParams = { + TableName: process.env.oneMacTableName, + IndexName: "GSI1", + ExclusiveStartKey: null, + ScanIndexForward: false, + ProjectionExpression: + "componentId,componentType,currentStatus,submissionTimestamp,latestRaiResponseTimestamp,lastActivityTimestamp,submitterName,submitterEmail,waiverAuthority, cpocName, reviewTeam, subStatus, finalDispositionDate", + }; + const grouppk = "OneMAC#" + group; + let paramList = []; + if (territoryList[0] !== "N/A") { + paramList = territoryList.map((territory) => { + return { + ...baseParams, + KeyConditionExpression: "GSI1pk = :pk AND begins_with(GSI1sk,:t1)", + ExpressionAttributeValues: { + ":pk": grouppk, + ":t1": territory, }, - ]; - } + }; + }); + } else { + paramList = [ + { + ...baseParams, + KeyConditionExpression: "GSI1pk = :pk", + ExpressionAttributeValues: { + ":pk": grouppk, + }, + }, + ]; + } - return Promise.all( - paramList.map(async (params) => { - const promiseItems = []; - do { - const results = await dynamoDb.query(params); - results.Items.map((oneItem) => { - oneItem.actions = getActionsForPackage( - oneItem.componentType, - oneItem.currentStatus, - !!oneItem.latestRaiResponseTimestamp, - oneItem.subStatus, - userRoleObj, - "package" - ); - if (oneItem.waiverAuthority) - oneItem.temporaryExtensionType = oneItem.waiverAuthority.slice( - 0, - 7 - ); + // Using a for...of loop to ensure async operations are awaited correctly + const allItems = []; + for (const params of paramList) { + const promiseItems = []; + do { + const results = await dynamoDb.query(params); + for (const oneItem of results.Items) { + oneItem.actions = await getActionsForPackage( + oneItem.componentType, + oneItem.currentStatus, + !!oneItem.latestRaiResponseTimestamp, + oneItem.subStatus, + userRoleObj, + "package" + ); + if (oneItem.waiverAuthority) + oneItem.temporaryExtensionType = oneItem.waiverAuthority.slice( + 0, + 7 + ); - if (statusMap[oneItem.subStatus]) { - oneItem.subStatus = statusMap[oneItem.subStatus]; - } + if (statusMap[oneItem.subStatus]) { + oneItem.subStatus = statusMap[oneItem.subStatus]; + } - if (!statusMap[oneItem.currentStatus]) - console.log( - "%s status of %s not mapped!", - oneItem.pk, - oneItem.currentStatus - ); - else { - oneItem.currentStatus = statusMap[oneItem.currentStatus]; - if ( - oneItem.currentStatus !== Workflow.ONEMAC_STATUS.INACTIVATED - ) - promiseItems.push(oneItem); - } - }); - params.ExclusiveStartKey = results.LastEvaluatedKey; - } while (params.ExclusiveStartKey); - return promiseItems; - }) - ).then((values) => { - return values.flat(); - }); - }) - .catch((error) => { - console.log("error is: ", error); - return error; - }); + if (!statusMap[oneItem.currentStatus]) + console.log( + "%s status of %s not mapped!", + oneItem.pk, + oneItem.currentStatus + ); + else { + oneItem.currentStatus = statusMap[oneItem.currentStatus]; + if (oneItem.currentStatus !== Workflow.ONEMAC_STATUS.INACTIVATED) + promiseItems.push(oneItem); + } + } + params.ExclusiveStartKey = results.LastEvaluatedKey; + } while (params.ExclusiveStartKey); + + allItems.push(...promiseItems); + } + + return allItems; // Flattened list of all items + } catch (error) { + console.log("error is: ", error); + return error; + } }; // get the approver list for a rols and possibly a territory diff --git a/services/app-api/libs/dynamodb-lib.js b/services/app-api/libs/dynamodb-lib.js index 0b9db5703..0e83ed568 100644 --- a/services/app-api/libs/dynamodb-lib.js +++ b/services/app-api/libs/dynamodb-lib.js @@ -15,4 +15,5 @@ export default { update: (params) => client.update(params).promise(), delete: (params) => client.delete(params).promise(), scan: (params) => client.scan(params).promise(), + batchWrite: (params) => client.batchWrite(params), }; diff --git a/services/app-api/notification/createUserNotifications.test.js b/services/app-api/notification/createUserNotifications.test.js new file mode 100644 index 000000000..157e23d6d --- /dev/null +++ b/services/app-api/notification/createUserNotifications.test.js @@ -0,0 +1,110 @@ +const { + createUserNotifications, + getUserTargetedSystemNotifications, + getAllUserNotifications, + insertMissingNotifications, +} = require("./createUserNotifications"); + +jest.mock("../libs/dynamodb-lib"); +jest.mock("./createUserNotifications"); + +describe("createUserNotifications", () => { + const mockUserId = "user@example.com"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should exit early if there are no active user-targeted system notifications", async () => { + // Mock no system notifications + getUserTargetedSystemNotifications.mockResolvedValue([]); + + const result = await createUserNotifications(mockUserId); + + expect(result).toEqual({ + statusCode: 200, + body: { + message: "No active user-targeted system notifications to sync.", + insertedCount: 0, + }, + }); + expect(getAllUserNotifications).not.toHaveBeenCalled(); // Ensure no further calls + expect(insertMissingNotifications).not.toHaveBeenCalled(); + }); + + it("should sync notifications if there are missing notifications", async () => { + const mockSystemNotifications = [ + { sk: "NOTIFICATION#1", GSI1pk: "USER_NOTIFICATION" }, + { sk: "NOTIFICATION#2", GSI1pk: "USER_NOTIFICATION" }, + ]; + const mockUserNotifications = [ + { sk: "NOTIFICATION#1" }, // User already has NOTIFICATION#1 + ]; + + // Mock system notifications + getUserTargetedSystemNotifications.mockResolvedValue( + mockSystemNotifications + ); + // Mock user notifications + getAllUserNotifications.mockResolvedValue(mockUserNotifications); + + // Mock insert missing notifications + insertMissingNotifications.mockResolvedValue(undefined); + + const result = await createUserNotifications(mockUserId); + + expect(result).toEqual({ + statusCode: 200, + body: { + message: "User notifications synced successfully.", + insertedCount: 1, // Only NOTIFICATION#2 should be inserted + }, + }); + + expect(getAllUserNotifications).toHaveBeenCalledWith(mockUserId); + expect(insertMissingNotifications).toHaveBeenCalledWith(mockUserId, [ + { sk: "NOTIFICATION#2", GSI1pk: "USER_NOTIFICATION" }, + ]); + }); + + it("should not insert notifications if user already has all system notifications", async () => { + const mockSystemNotifications = [ + { sk: "NOTIFICATION#1", GSI1pk: "USER_NOTIFICATION" }, + ]; + const mockUserNotifications = [ + { sk: "NOTIFICATION#1" }, // User already has all notifications + ]; + + // Mock system and user notifications + getUserTargetedSystemNotifications.mockResolvedValue( + mockSystemNotifications + ); + getAllUserNotifications.mockResolvedValue(mockUserNotifications); + + const result = await createUserNotifications(mockUserId); + + expect(result).toEqual({ + statusCode: 200, + body: { + message: "User notifications synced successfully.", + insertedCount: 0, + }, + }); + + expect(getAllUserNotifications).toHaveBeenCalledWith(mockUserId); + expect(insertMissingNotifications).not.toHaveBeenCalled(); // No missing notifications, so no insert + }); + + it("should handle errors from getUserTargetedSystemNotifications", async () => { + // Mock system notifications to throw an error + getUserTargetedSystemNotifications.mockRejectedValue( + new Error("DynamoDB error") + ); + + await expect(createUserNotifications(mockUserId)).rejects.toThrow( + "DynamoDB error" + ); + expect(getAllUserNotifications).not.toHaveBeenCalled(); // Ensure no further calls + expect(insertMissingNotifications).not.toHaveBeenCalled(); + }); +}); diff --git a/services/app-api/notification/createUserNotifications.ts b/services/app-api/notification/createUserNotifications.ts new file mode 100644 index 000000000..2bc04e1cb --- /dev/null +++ b/services/app-api/notification/createUserNotifications.ts @@ -0,0 +1,145 @@ +import dynamoDb from "../libs/dynamodb-lib"; // Import shared DynamoDB library +import handler from "../libs/handler-lib"; // Lambda handler wrapper +import { dismissUserNotification } from "./dismissUserNotification"; +import { Notification } from "./notification"; + +export const getUserTargetedSystemNotifications = async () => { + const currentDate = new Date().toISOString(); + + const params = { + TableName: process.env.oneMacTableName, + IndexName: "GSI1", + KeyConditionExpression: + "GSI1pk = :userNotification AND GSI1sk BETWEEN :pubStart AND :pubEnd", + FilterExpression: + "pk = :system AND (expiryDate >= :currentDate OR attribute_not_exists(expiryDate))", // pk filter and expiry check + ExpressionAttributeValues: { + ":userNotification": "USER_NOTIFICATION", // GSI1pk for user notifications + ":pubStart": `0000-01-01T00:00:00Z#${currentDate}`, // Start from the earliest possible date + ":pubEnd": `${currentDate}#9999-12-31T23:59:59Z`, // End at the far future expiry date + ":system": "SYSTEM", // Ensure pk is SYSTEM + ":currentDate": currentDate, // Current date to filter out expired notifications + }, + }; + + const result = await dynamoDb.query(params); + return result.Items; +}; + +// Function to get all user notifications (including dismissed) +export const getAllUserNotifications = async ( + userId: string +): Promise => { + const params = { + TableName: process.env.oneMacTableName, + KeyConditionExpression: "pk = :user AND begins_with(sk, :notification)", + ExpressionAttributeValues: { + ":user": `USER#${userId}`, + ":notification": "NOTIFICATION#", + }, + }; + + const result = await dynamoDb.query(params); + return result.Items as Notification[]; +}; + +// Function to insert missing notifications +export const insertMissingNotifications = async ( + userId: string, + notifications: Notification[] +) => { + const putRequests = notifications.map((notification) => { + const newNotification = { + ...notification, // Copy notification data from system notification + pk: `USER#${userId}`, + dismissed: false, // Default to not dismissed for the user + }; + + return { + PutRequest: { + Item: newNotification, + }, + }; + }); + + const params = { + RequestItems: { + [process.env.oneMacTableName as string]: putRequests, + }, + }; + + console.log( + "Inserting missing notifications:", + JSON.stringify(putRequests, null, 2) + ); + + // DynamoDB batch write to insert multiple items + await dynamoDb.batchWrite(params).promise(); +}; + +// Main Lambda function to create user notifications upon login +export const createUserNotifications = async (userId: string) => { + // Step 1: Get all user-targeted system notifications + const systemNotifications: Notification[] = + (await getUserTargetedSystemNotifications()) as Notification[]; + + // Exit early if there are no active user-targeted system notifications + if (systemNotifications.length === 0) { + console.log("No active user-targeted system notifications found."); + return { + statusCode: 200, + body: { + message: "No active user-targeted system notifications to sync.", + insertedCount: 0, + }, + }; + } + + // Step 2: Get all user notifications (including dismissed) + const userNotifications = + ((await getAllUserNotifications(userId)) as Notification[]) || + ([] as Notification[]); + + // Step 3: Find the missing notifications + const existingNotificationIds = new Set( + userNotifications.map((notif) => notif.sk.split("#")[1]) // Extract notificationId from user notification sk + ); + + // Filter system notifications to find the ones not yet created for this user + const missingNotifications: Notification[] = systemNotifications.filter( + (notif) => !existingNotificationIds.has(notif.sk.split("#")[1]) // Compare system notificationId with user notificationId + ); + + // mark existing notifications as dismissed + existingNotificationIds.forEach(async (notificationId) => { + dismissUserNotification(userId, notificationId); + }); + + // Step 4: Insert missing notifications + if (missingNotifications.length > 0) { + await insertMissingNotifications(userId, missingNotifications); + } + + return { + statusCode: 200, + body: { + message: "User notifications synced successfully.", + insertedCount: missingNotifications.length, + notifications: missingNotifications, + }, + }; +}; + +export const main = handler(async (event) => { + const userId = event.pathParameters?.userId; // Extract the userId from path parameters + + if (!userId) { + return { + statusCode: 400, + body: { message: "userEmail is required" }, + }; + } + + // Call the function to create missing user notifications, passing the email + return createUserNotifications(userId); +}); diff --git a/services/app-api/notification/dismissUserNotification.test.js b/services/app-api/notification/dismissUserNotification.test.js new file mode 100644 index 000000000..764b2496e --- /dev/null +++ b/services/app-api/notification/dismissUserNotification.test.js @@ -0,0 +1,68 @@ +import dynamoDb from "../libs/dynamodb-lib"; +import { main, dismissUserNotification } from "./dismissUserNotification"; + +jest.mock("../libs/dynamodb-lib"); + +beforeAll(() => { + jest.clearAllMocks(); +}); + +const testEvent = { + pathParameters: { + userId: "12345", + notificationId: "1", + }, +}; + +beforeEach(() => { + dynamoDb.update.mockResolvedValue({ Attributes: { dismissed: true } }); +}); + +describe("dismissUserNotification", () => { + it("marks a notification as dismissed", async () => { + await expect(dismissUserNotification("12345", "1")).resolves.toStrictEqual({ + statusCode: 200, + body: { dismissed: true }, + }); + }); + + it("handles dynamoDb update error", async () => { + const expectedResponse = { + statusCode: 500, + body: { + message: "Error dismissing user notification", + }, + }; + + dynamoDb.update.mockRejectedValueOnce("DynamoDB Error"); + + await expect(main(testEvent)).resolves.toStrictEqual({ + statusCode: 200, // Due to the handler wrapping + body: JSON.stringify(expectedResponse), // The body is stringified + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": true, + }, + }); + }); + + it("returns validation error if userId or notificationId is missing", async () => { + const expectedResponse = { + statusCode: 400, + body: { + message: "userId and notificationId are required", + }, + }; + + const noUserEvent = { pathParameters: { notificationId: "1" } }; + + await expect(main(noUserEvent)).resolves.toStrictEqual({ + statusCode: 200, // Due to the handler wrapping + body: JSON.stringify(expectedResponse), // The body is stringified + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": true, + }, + }); + }); +}); diff --git a/services/app-api/notification/dismissUserNotification.ts b/services/app-api/notification/dismissUserNotification.ts new file mode 100644 index 000000000..c45b6801c --- /dev/null +++ b/services/app-api/notification/dismissUserNotification.ts @@ -0,0 +1,52 @@ +import dynamoDb from "../libs/dynamodb-lib"; // Import shared DynamoDB library +import handler from "../libs/handler-lib"; // Lambda handler wrapper + +export const dismissUserNotification = async ( + userId: string, + notificationId: string +) => { + const params = { + TableName: process.env.oneMacTableName, // Environment variable for the table name + Key: { + pk: `USER#${userId}`, // Partition key for the user + sk: `NOTIFICATION#${notificationId}`, // Sort key for the notification + }, + UpdateExpression: "SET dismissed = :dismissed", + ExpressionAttributeValues: { + ":dismissed": true, // Mark the notification as dismissed + }, + ReturnValues: "UPDATED_NEW", // Return the updated attributes + }; + + try { + const result = await dynamoDb.update(params); + return { + statusCode: 200, + body: result.Attributes, // Return the updated notification attributes + }; + } catch (error) { + console.error("Error dismissing user notification:", error); + return { + statusCode: 500, + body: { + message: "Error dismissing user notification", + }, + }; + } +}; + +// Lambda handler function +export const main = handler(async (event) => { + const userId = event.pathParameters?.userId; // Extract the userId from path parameters + const notificationId = event.pathParameters?.notificationId; // Extract the notificationId from path parameters + + if (!userId || !notificationId) { + return { + statusCode: 400, + body: { message: "userId and notificationId are required" }, + }; + } + + // Call the function to mark the notification as dismissed + return dismissUserNotification(userId, notificationId); +}); diff --git a/services/app-api/notification/getActiveSystemNotifications.test.js b/services/app-api/notification/getActiveSystemNotifications.test.js new file mode 100644 index 000000000..72b96d596 --- /dev/null +++ b/services/app-api/notification/getActiveSystemNotifications.test.js @@ -0,0 +1,71 @@ +import dynamoDb from "../libs/dynamodb-lib"; +import { + main, + getActiveSystemNotifications, +} from "./getActiveSystemNotifications"; + +jest.mock("../libs/dynamodb-lib"); + +beforeAll(() => { + jest.clearAllMocks(); +}); + +const testEvent = { + queryStringParameters: null, + pathParameters: null, +}; + +const systemNotificationItems = [ + { + GSI1pk: "SYSTEM", + GSI1sk: "2024-01-01T00:00:00Z#2025-01-01T00:00:00Z", + header: "System Notification 1", + }, + { + GSI1pk: "SYSTEM", + GSI1sk: "2024-06-01T00:00:00Z#2025-06-01T00:00:00Z", + header: "System Notification 2", + }, +]; + +beforeEach(() => { + dynamoDb.query.mockResolvedValue({ Items: systemNotificationItems }); +}); + +describe("getActiveSystemNotifications", () => { + it("returns active system notifications", async () => { + await expect(getActiveSystemNotifications()).resolves.toStrictEqual({ + statusCode: "200", // Status code as string + body: systemNotificationItems, + }); + }); + + it("returns empty array if no system notifications", async () => { + dynamoDb.query.mockResolvedValueOnce({ Items: [] }); + + await expect(getActiveSystemNotifications()).resolves.toStrictEqual({ + statusCode: "200", // Status code as string + body: [], + }); + }); + + it("handles dynamoDb query error", async () => { + const expectedResponse = { + statusCode: "SY000", + body: { + message: "There was an error fetching the active system notifications.", + }, + }; + + dynamoDb.query.mockRejectedValueOnce("DynamoDB Error"); + + await expect(main(testEvent)).resolves.toStrictEqual({ + statusCode: 200, // Due to the handler wrapping + body: JSON.stringify(expectedResponse), // The body is stringified + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": true, + }, + }); + }); +}); diff --git a/services/app-api/notification/getActiveSystemNotifications.ts b/services/app-api/notification/getActiveSystemNotifications.ts new file mode 100644 index 000000000..51e510dcb --- /dev/null +++ b/services/app-api/notification/getActiveSystemNotifications.ts @@ -0,0 +1,58 @@ +import dynamoDb from "../libs/dynamodb-lib"; +import { RESPONSE_CODE } from "cmscommonlib"; +import handler from "../libs/handler-lib"; + +/** + * Get active system notifications + * Returns an empty array if no notifications are found + * @returns the list of active system notifications or an empty array + */ +export const getActiveSystemNotifications = async () => { + const currentDate = new Date().toISOString(); + console.log("oneMacTableName:", process.env.oneMacTableName); // Log the env variable + + const params = { + TableName: process.env.oneMacTableName, // Use the environment variable for the table name + IndexName: "GSI1", // Use your GSI1 index name + KeyConditionExpression: + "GSI1pk = :systemType AND GSI1sk BETWEEN :pubStart AND :pubEnd", + FilterExpression: + "expiryDate >= :currentDate OR attribute_not_exists(expiryDate)", + ExpressionAttributeValues: { + ":systemType": "SYSTEM", // For system notifications + ":pubStart": `0000-01-01T00:00:00Z#${currentDate}`, // Start from the earliest possible date + ":pubEnd": `${currentDate}#9999-12-31T23:59:59Z`, // End at the far-future expiry date + ":currentDate": currentDate, // Use the current date to filter out expired records + }, + }; + + try { + const result = await dynamoDb.query(params); // Using the custom dynamoDb query method + return result.Items || []; // Return the list of notifications or an empty array if none + } catch (error) { + console.log("Error fetching system notifications: ", error); + return { + statusCode: RESPONSE_CODE.SYSTEM_ERROR, + body: { + message: "There was an error fetching the active system notifications.", + }, + }; + } +}; + +/** + * Main handler function to expose via API Gateway or Lambda + */ +export const main = handler(async () => { + try { + return getActiveSystemNotifications(); + } catch (error) { + console.log("Error: ", error); + return { + statusCode: RESPONSE_CODE.SERVER_ERROR, + body: JSON.stringify({ + message: "Internal Server Error.", + }), + }; + } +}); diff --git a/services/app-api/notification/getActiveUserNotifications.test.js b/services/app-api/notification/getActiveUserNotifications.test.js new file mode 100644 index 000000000..2c84e84d2 --- /dev/null +++ b/services/app-api/notification/getActiveUserNotifications.test.js @@ -0,0 +1,90 @@ +import dynamoDb from "../libs/dynamodb-lib"; +import { main, getActiveUserNotifications } from "./getActiveUserNotifications"; + +jest.mock("../libs/dynamodb-lib"); + +beforeAll(() => { + jest.clearAllMocks(); +}); + +const testEvent = { + pathParameters: { + userId: "12345", + }, +}; + +const userNotificationItems = [ + { + pk: "USER#12345", + sk: "NOTIFICATION#1", + header: "User Notification 1", + dismissed: false, + }, + { + pk: "USER#12345", + sk: "NOTIFICATION#2", + header: "User Notification 2", + dismissed: false, + }, +]; + +beforeEach(() => { + dynamoDb.query.mockResolvedValue({ Items: userNotificationItems }); +}); + +describe("getActiveUserNotifications", () => { + it("returns active user notifications", async () => { + await expect(getActiveUserNotifications("12345")).resolves.toStrictEqual({ + statusCode: 200, + body: userNotificationItems, + }); + }); + + it("returns empty array if no user notifications", async () => { + dynamoDb.query.mockResolvedValueOnce({ Items: [] }); + await expect(getActiveUserNotifications("12345")).resolves.toStrictEqual({ + statusCode: 200, + body: [], + }); + }); + + it("handles dynamoDb query error", async () => { + const expectedResponse = { + statusCode: 500, + body: { + message: "Error fetching active user notifications", + }, + }; + + dynamoDb.query.mockRejectedValueOnce("DynamoDB Error"); + + await expect(main(testEvent)).resolves.toStrictEqual({ + statusCode: 200, // Due to the handler wrapping + body: JSON.stringify(expectedResponse), // The body is stringified + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": true, + }, + }); + }); + + it("returns validation error if userId is missing", async () => { + const expectedResponse = { + statusCode: 400, + body: { + message: "userId is required", + }, + }; + + const noUserEvent = { pathParameters: {} }; + + await expect(main(noUserEvent)).resolves.toStrictEqual({ + statusCode: 200, // Due to the handler wrapping + body: JSON.stringify(expectedResponse), // The body is stringified + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": true, + }, + }); + }); +}); diff --git a/services/app-api/notification/getActiveUserNotifications.ts b/services/app-api/notification/getActiveUserNotifications.ts new file mode 100644 index 000000000..963955089 --- /dev/null +++ b/services/app-api/notification/getActiveUserNotifications.ts @@ -0,0 +1,46 @@ +import dynamoDb from "../libs/dynamodb-lib"; +import handler from "../libs/handler-lib"; + +export const getActiveUserNotifications = async (userId: string) => { + const currentDate = new Date().toISOString(); + + const params = { + TableName: process.env.oneMacTableName, // Environment variable for the table name + KeyConditionExpression: "pk = :user AND begins_with(sk, :prefix)", // Query by user and notification prefix + FilterExpression: + "dismissed = :dismissed AND publicationDate <= :currentDate AND (expiryDate >= :currentDate OR attribute_not_exists(expiryDate))", + ExpressionAttributeValues: { + ":user": `USER#${userId}`, // Partition key for the user + ":prefix": "NOTIFICATION#", // Sort key prefix for notifications + ":dismissed": false, // Only return notifications that have not been dismissed + ":currentDate": currentDate, // Filter by current date for publication and expiry dates + }, + }; + + try { + const result = await dynamoDb.query(params); + return result.Items || []; + } catch (error) { + console.error("Error fetching user notifications:", error); + return { + statusCode: 500, + body: { + message: "Error fetching active user notifications", + }, + }; + } +}; + +// Lambda handler function +export const main = handler(async (event) => { + const userId = event.pathParameters?.userId; // Extract the userId from path parameters or query string + if (!userId) { + return { + statusCode: 400, + body: { message: "userId is required" }, + }; + } + + // Call the function to get active user notifications + return getActiveUserNotifications(userId); +}); diff --git a/services/app-api/notification/notification.ts b/services/app-api/notification/notification.ts new file mode 100644 index 000000000..166460716 --- /dev/null +++ b/services/app-api/notification/notification.ts @@ -0,0 +1,13 @@ +export interface Notification { + pk: string; + sk: string; + notificationId: string; + header: string; + body: string; + buttonText?: string; + buttonLink?: string; + publicationDate: string; + expiryDate?: string; + activeStatus: boolean; + notificationType: string; +} diff --git a/services/app-api/package-lock.json b/services/app-api/package-lock.json index 95df4ad6d..c93cdca26 100644 --- a/services/app-api/package-lock.json +++ b/services/app-api/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "CC0-1.0", "dependencies": { + "@launchdarkly/node-server-sdk": "^9.7.2", "cmscommonlib": "file:../common", "date-fns": "^2.16.1", "dynamodb-local": "^0.0.32", @@ -5995,6 +5996,60 @@ "dev": true, "peer": true }, + "node_modules/@launchdarkly/js-sdk-common": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@launchdarkly/js-sdk-common/-/js-sdk-common-2.12.0.tgz", + "integrity": "sha512-HIDxvgo1vksC9hsYy3517sgW0Ql+iW3fgwlq/CEigeBNmaa9/J1Pxo7LrKPzezEA0kaGedmt/DCzVVxVBmxSsQ==" + }, + "node_modules/@launchdarkly/js-server-sdk-common": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@launchdarkly/js-server-sdk-common/-/js-server-sdk-common-2.10.0.tgz", + "integrity": "sha512-zbqpmEFQW/ZElZnRYX6N4gMZMpviE0F75/IyxcifLAFsjGNouxllpOOPbdtrLiJnJ0ixzt5vbtnem4tbhlYNOw==", + "dependencies": { + "@launchdarkly/js-sdk-common": "2.12.0", + "semver": "7.5.4" + } + }, + "node_modules/@launchdarkly/js-server-sdk-common/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@launchdarkly/js-server-sdk-common/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@launchdarkly/js-server-sdk-common/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/@launchdarkly/node-server-sdk": { + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/@launchdarkly/node-server-sdk/-/node-server-sdk-9.7.2.tgz", + "integrity": "sha512-gcRarEh0yQrlwbWDORwbfTk19M/FtZje60EIo/c4298D/sqJ906MYq0J2MmyklEuIdQx/V4qPK+ss9LCCLpm/Q==", + "dependencies": { + "@launchdarkly/js-server-sdk-common": "2.10.0", + "https-proxy-agent": "^5.0.1", + "launchdarkly-eventsource": "2.0.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7434,8 +7489,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "peer": true, "dependencies": { "debug": "4" }, @@ -11468,8 +11521,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "peer": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -15575,6 +15626,14 @@ "node": ">=6" } }, + "node_modules/launchdarkly-eventsource": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/launchdarkly-eventsource/-/launchdarkly-eventsource-2.0.3.tgz", + "integrity": "sha512-VhFjppK7jXlcEKaS7bxdoibB5j01NKyeDR7a8XfssdDGNWCTsbF0/5IExSmPi44eDncPhkoPNxlSZhEZvrbD5w==", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", diff --git a/services/app-api/package.json b/services/app-api/package.json index 57adc4c08..8c91d0120 100644 --- a/services/app-api/package.json +++ b/services/app-api/package.json @@ -14,8 +14,8 @@ }, "devDependencies": { "@types/jest": "^29.5.5", - "aws-sdk-client-mock": "^0.5.6", "aws-sdk": "^2.752.0", + "aws-sdk-client-mock": "^0.5.6", "esbuild": "^0.19.4", "esbuild-jest": "^0.5.0", "jest": "^29.7.0", @@ -28,6 +28,7 @@ "xstate": "^4.26.0" }, "dependencies": { + "@launchdarkly/node-server-sdk": "^9.7.2", "cmscommonlib": "file:../common", "date-fns": "^2.16.1", "dynamodb-local": "^0.0.32", diff --git a/services/app-api/resources/roles.yml b/services/app-api/resources/roles.yml index af8311986..9bb9feaa3 100644 --- a/services/app-api/resources/roles.yml +++ b/services/app-api/resources/roles.yml @@ -69,6 +69,7 @@ Resources: - dynamodb:PutItem - dynamodb:UpdateItem - dynamodb:DeleteItem + - dynamodb:BatchWriteItem Resource: arn:aws:dynamodb:*:*:table/${self:custom.oneMacTableName} - Effect: Allow Action: diff --git a/services/app-api/serverless.yml b/services/app-api/serverless.yml index f22c2ea5d..b1b736d96 100644 --- a/services/app-api/serverless.yml +++ b/services/app-api/serverless.yml @@ -33,6 +33,7 @@ custom: applicationEndpoint: ${cf:ui-${self:custom.stage}.ApplicationEndpointUrl, "onemac.cms.gov"} attachmentsBucket: ${cf:uploads-${self:custom.stage}.AttachmentsBucketName} attachmentsBucketArn: ${cf:uploads-${self:custom.stage}.AttachmentsBucketArn} + launchDarklySdkKey: ${ssm:/configuration/${self:custom.stage}/launchdarkly/sdkkey, ssm:/configuration/default/launchdarkly/sdkkey} warmupEnabled: production: true development: false @@ -87,6 +88,7 @@ provider: applicationEndpoint: ${self:custom.applicationEndpoint} attachmentsBucket: ${self:custom.attachmentsBucket} configurationSetName: 'email-${self:custom.stage}-configuration' + launchDarklySdkKey: ${self:custom.launchDarklySdkKey} tracing: apiGateway: true lambda: true @@ -635,6 +637,45 @@ functions: cors: true authorizer: aws_iam + getActiveSystemNotifications: + handler: notification/getActiveSystemNotifications.main + role: LambdaApiRole + events: + - http: + path: getActiveSystemNotifications # The API path + method: get + cors: true + + getActiveUserNotifications: + handler: notification/getActiveUserNotifications.main # Path to your function handler + role: LambdaApiRole # Ensure the role has permissions for DynamoDB + events: + - http: + path: getActiveUserNotifications/{userId} # Define your API path + method: get + cors: true + authorizer: aws_iam + + dismissUserNotification: + handler: notification/dismissUserNotification.main + role: LambdaApiRole + events: + - http: + path: dismissNotification/{userId}/{notificationId} + method: patch + cors: true + authorizer: aws_iam + + createUserNotifications: + handler: notification/createUserNotifications.main + role: LambdaApiRole + events: + - http: + path: createUserNotifications/{userId} + method: post + cors: true + authorizer: aws_iam + resources: - ${file(resources/base.yml)} diff --git a/services/app-api/utils/actionDelegate.js b/services/app-api/utils/actionDelegate.js index 68b6acc04..ee6340968 100644 --- a/services/app-api/utils/actionDelegate.js +++ b/services/app-api/utils/actionDelegate.js @@ -1,4 +1,26 @@ import { Workflow } from "cmscommonlib"; +import { init } from "@launchdarkly/node-server-sdk"; + +// Global variable to hold the LD client so it's reused on subsequent invocations +let ldClient; + +async function initializeLaunchDarkly() { + if (!ldClient) { + ldClient = init(process.env.launchDarklySdkKey, { + baseUri: "https://clientsdk.launchdarkly.us", + streamUri: "https://clientstream.launchdarkly.us", + eventsUri: "https://events.launchdarkly.us", + }); + + try { + await ldClient.waitForInitialization({ timeout: 10 }); + console.log("LD Initialization successful"); + } catch (err) { + console.error("LD Initialization failed:", err.message || err); + } + } +} + function getDefaultActions( packageStatus, hasRaiResponse, @@ -74,7 +96,7 @@ function getWaiverExtensionActions(packageStatus, userRole) { return actions; } -export function getActionsForPackage( +export async function getActionsForPackage( packageType, packageStatus, hasRaiResponse, @@ -82,6 +104,16 @@ export function getActionsForPackage( userRole, formSource ) { + // Initialize LaunchDarkly client (if not already initialized) + await initializeLaunchDarkly(); + + const ENABLE_SUBSEQUENT_SUBMISSION = await ldClient.variation( + "enableSubsequentDocumentation", + { key: userRole }, + false + ); + console.log("Flag value:", ENABLE_SUBSEQUENT_SUBMISSION); + let actions = getDefaultActions( packageStatus, hasRaiResponse, @@ -114,9 +146,18 @@ export function getActionsForPackage( ); break; } - // Filter out duplicates - const uniqueActions = actions.filter( - (action, index) => actions.indexOf(action) === index - ); + + const uniqueActions = actions.filter((action, index) => { + // Filter out SUBSEQUENT_SUBMISSION if not enabled + const isNotSubsequentSubmission = + action !== Workflow.PACKAGE_ACTION.SUBSEQUENT_SUBMISSION || + ENABLE_SUBSEQUENT_SUBMISSION; + + // Remove duplicates: only keep the first occurrence of each action + const isUnique = actions.indexOf(action) === index; + + return isNotSubsequentSubmission && isUnique; + }); + return uniqueActions; } diff --git a/services/common/index.js b/services/common/index.js index 73c391eb7..edbd1dabb 100644 --- a/services/common/index.js +++ b/services/common/index.js @@ -115,6 +115,7 @@ export const RESPONSE_CODE = { SUBMISSION_ID_EXIST_WARNING: "OMP003", RAI_RESPONSE_WITHDRAW_ENABLE_SUCCESS: "RE000", RAI_RESPONSE_WITHDRAW_DISABLE_SUCCESS: "RE001", + OK: "200", }; export const FORM_SUCCESS_RESPONSE_CODES = [ diff --git a/services/ui-src/package-lock.json b/services/ui-src/package-lock.json index 45f41d7ea..711d6a7b0 100644 --- a/services/ui-src/package-lock.json +++ b/services/ui-src/package-lock.json @@ -28,6 +28,7 @@ "isomorphic-fetch": "^3.0.0", "jszip": "^3.6.0", "jwt-decode": "^3.1.2", + "launchdarkly-react-client-sdk": "^3.5.0", "promise-polyfill": "8.3.0", "react": "^16.13.1", "react-bootstrap": "^0.33.1", @@ -22195,6 +22196,69 @@ "shell-quote": "^1.8.1" } }, + "node_modules/launchdarkly-js-client-sdk": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/launchdarkly-js-client-sdk/-/launchdarkly-js-client-sdk-3.5.0.tgz", + "integrity": "sha512-3dgxC9S8K2ix6qjdArjZGOJPtAytgfQTuE+vWgjWJK7725rpYbuqbHghIFr5B0+WyWyVBYANldjWd1JdtYLwsw==", + "license": "Apache-2.0", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "launchdarkly-js-sdk-common": "5.4.0" + } + }, + "node_modules/launchdarkly-js-client-sdk/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/launchdarkly-js-sdk-common": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/launchdarkly-js-sdk-common/-/launchdarkly-js-sdk-common-5.4.0.tgz", + "integrity": "sha512-Kb3SDcB6S0HUpFNBZgtEt0YUV/fVkyg+gODfaOCJQ0Y0ApxLKNmmJBZOrPE2qIdzw536u4BqEjtaJdqJWCEElg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "fast-deep-equal": "^2.0.1", + "uuid": "^8.0.0" + } + }, + "node_modules/launchdarkly-js-sdk-common/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, + "node_modules/launchdarkly-js-sdk-common/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/launchdarkly-react-client-sdk": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/launchdarkly-react-client-sdk/-/launchdarkly-react-client-sdk-3.5.0.tgz", + "integrity": "sha512-nBWg079lV7bZP4ZGcyvqAygzued1ySbBNo6/k7J1m1ReCclTZAyFF78ykfaZmFkTdRC9km5l4dHw81ahqzD2YQ==", + "license": "Apache-2.0", + "dependencies": { + "hoist-non-react-statics": "^3.3.2", + "launchdarkly-js-client-sdk": "^3.5.0", + "lodash.camelcase": "^4.3.0" + }, + "peerDependencies": { + "react": "^16.6.3 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.4 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -22276,6 +22340,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", diff --git a/services/ui-src/package.json b/services/ui-src/package.json index 4913b861a..953ebe011 100644 --- a/services/ui-src/package.json +++ b/services/ui-src/package.json @@ -23,6 +23,7 @@ "isomorphic-fetch": "^3.0.0", "jszip": "^3.6.0", "jwt-decode": "^3.1.2", + "launchdarkly-react-client-sdk": "^3.5.0", "promise-polyfill": "8.3.0", "react": "^16.13.1", "react-bootstrap": "^0.33.1", diff --git a/services/ui-src/public/assets/docs/IG_ABP10_GeneralAssurances.doc b/services/ui-src/public/assets/docs/IG_ABP10_GeneralAssurances.doc new file mode 100644 index 000000000..8ba9be2f9 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP10_GeneralAssurances.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP11_PaymentMethodology.doc b/services/ui-src/public/assets/docs/IG_ABP11_PaymentMethodology.doc new file mode 100644 index 000000000..f5ab2734b Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP11_PaymentMethodology.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP1_AlternativeBenefitPlanPopulations.doc b/services/ui-src/public/assets/docs/IG_ABP1_AlternativeBenefitPlanPopulations.doc new file mode 100644 index 000000000..1de807011 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP1_AlternativeBenefitPlanPopulations.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP2a_VoluntaryBenefitPackageAssurances.doc b/services/ui-src/public/assets/docs/IG_ABP2a_VoluntaryBenefitPackageAssurances.doc new file mode 100644 index 000000000..c1affd052 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP2a_VoluntaryBenefitPackageAssurances.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP2b_VoluntaryEnrollmentAssurances.doc b/services/ui-src/public/assets/docs/IG_ABP2b_VoluntaryEnrollmentAssurances.doc new file mode 100644 index 000000000..6f1a4b0dd Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP2b_VoluntaryEnrollmentAssurances.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP2c_EnrollmentAssurancesMandatoryParticipants.doc b/services/ui-src/public/assets/docs/IG_ABP2c_EnrollmentAssurancesMandatoryParticipants.doc new file mode 100644 index 000000000..74fd9ff5f Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP2c_EnrollmentAssurancesMandatoryParticipants.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP3.1_SelectionOfBenchmark20190819-Final.docx b/services/ui-src/public/assets/docs/IG_ABP3.1_SelectionOfBenchmark20190819-Final.docx new file mode 100644 index 000000000..6d32f8257 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP3.1_SelectionOfBenchmark20190819-Final.docx differ diff --git a/services/ui-src/public/assets/docs/IG_ABP3_SelectionOfBenchmark.doc b/services/ui-src/public/assets/docs/IG_ABP3_SelectionOfBenchmark.doc new file mode 100644 index 000000000..b034e4e8d Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP3_SelectionOfBenchmark.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP3_SelectionOfBenchmark20190819-Final.docx b/services/ui-src/public/assets/docs/IG_ABP3_SelectionOfBenchmark20190819-Final.docx new file mode 100644 index 000000000..ccd4cd0f0 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP3_SelectionOfBenchmark20190819-Final.docx differ diff --git a/services/ui-src/public/assets/docs/IG_ABP4_AbpCostSharing.doc b/services/ui-src/public/assets/docs/IG_ABP4_AbpCostSharing.doc new file mode 100644 index 000000000..be593602c Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP4_AbpCostSharing.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP5_BenefitsDescription-Final.docx b/services/ui-src/public/assets/docs/IG_ABP5_BenefitsDescription-Final.docx new file mode 100644 index 000000000..e8f886099 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP5_BenefitsDescription-Final.docx differ diff --git a/services/ui-src/public/assets/docs/IG_ABP6_BenchmarkEquivalentBenefit.doc b/services/ui-src/public/assets/docs/IG_ABP6_BenchmarkEquivalentBenefit.doc new file mode 100644 index 000000000..8f55d95c8 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP6_BenchmarkEquivalentBenefit.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP7_BenefitAssurances.doc b/services/ui-src/public/assets/docs/IG_ABP7_BenefitAssurances.doc new file mode 100644 index 000000000..9435ca96c Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP7_BenefitAssurances.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP8_ServiceDeliverySystems.doc b/services/ui-src/public/assets/docs/IG_ABP8_ServiceDeliverySystems.doc new file mode 100644 index 000000000..6b19d479f Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP8_ServiceDeliverySystems.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ABP9_EmployerSponsoredInsurance.doc b/services/ui-src/public/assets/docs/IG_ABP9_EmployerSponsoredInsurance.doc new file mode 100644 index 000000000..610df91b8 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ABP9_EmployerSponsoredInsurance.doc differ diff --git a/services/ui-src/public/assets/docs/IG_AbpIntroduction.doc b/services/ui-src/public/assets/docs/IG_AbpIntroduction.doc new file mode 100644 index 000000000..e85659c41 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_AbpIntroduction.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS10_ChildrenWhoHaveAccessToPublicEmployeeCoverage.doc b/services/ui-src/public/assets/docs/IG_CS10_ChildrenWhoHaveAccessToPublicEmployeeCoverage.doc new file mode 100644 index 000000000..93a81bf64 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS10_ChildrenWhoHaveAccessToPublicEmployeeCoverage.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS11_PregnantWomenWhoHaveAccessToPublicEmployeeCoverage.doc b/services/ui-src/public/assets/docs/IG_CS11_PregnantWomenWhoHaveAccessToPublicEmployeeCoverage.doc new file mode 100644 index 000000000..0e7679f27 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS11_PregnantWomenWhoHaveAccessToPublicEmployeeCoverage.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS12_DentalOnlySupplementalCoverage.doc b/services/ui-src/public/assets/docs/IG_CS12_DentalOnlySupplementalCoverage.doc new file mode 100644 index 000000000..2a074ef46 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS12_DentalOnlySupplementalCoverage.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS13_DeemedNewborns.doc b/services/ui-src/public/assets/docs/IG_CS13_DeemedNewborns.doc new file mode 100644 index 000000000..07de90ad9 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS13_DeemedNewborns.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS14_ChildrenIneligForMedicaid.doc b/services/ui-src/public/assets/docs/IG_CS14_ChildrenIneligForMedicaid.doc new file mode 100644 index 000000000..71f9678b3 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS14_ChildrenIneligForMedicaid.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS15_MAGI-BasedIncomeMethodologies.doc b/services/ui-src/public/assets/docs/IG_CS15_MAGI-BasedIncomeMethodologies.doc new file mode 100644 index 000000000..aba396fc7 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS15_MAGI-BasedIncomeMethodologies.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS16_Spenddown.doc b/services/ui-src/public/assets/docs/IG_CS16_Spenddown.doc new file mode 100644 index 000000000..fffe2c2c9 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS16_Spenddown.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS17_Non-Financial-Residency.doc b/services/ui-src/public/assets/docs/IG_CS17_Non-Financial-Residency.doc new file mode 100644 index 000000000..2c60b1d59 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS17_Non-Financial-Residency.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS18_Non-Financial-Citizenship.doc b/services/ui-src/public/assets/docs/IG_CS18_Non-Financial-Citizenship.doc new file mode 100644 index 000000000..40f3ef2ea Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS18_Non-Financial-Citizenship.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS19_Non-Financial-SocialSecurityNumber.doc b/services/ui-src/public/assets/docs/IG_CS19_Non-Financial-SocialSecurityNumber.doc new file mode 100644 index 000000000..246f5acce Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS19_Non-Financial-SocialSecurityNumber.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS20_Non-Financial-SubstitutionOfCoverage.doc b/services/ui-src/public/assets/docs/IG_CS20_Non-Financial-SubstitutionOfCoverage.doc new file mode 100644 index 000000000..e8980abde Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS20_Non-Financial-SubstitutionOfCoverage.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS21_NonFinancialNonPaymentOfPremiums.doc b/services/ui-src/public/assets/docs/IG_CS21_NonFinancialNonPaymentOfPremiums.doc new file mode 100644 index 000000000..1e24e0fb0 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS21_NonFinancialNonPaymentOfPremiums.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS23_NonFinancialRequirementOtherEligibilityStandards.doc b/services/ui-src/public/assets/docs/IG_CS23_NonFinancialRequirementOtherEligibilityStandards.doc new file mode 100644 index 000000000..a672677e4 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS23_NonFinancialRequirementOtherEligibilityStandards.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS24_EligibilityProcessing.doc b/services/ui-src/public/assets/docs/IG_CS24_EligibilityProcessing.doc new file mode 100644 index 000000000..a12d86753 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS24_EligibilityProcessing.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility.doc b/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility.doc new file mode 100644 index 000000000..1b6b5543a Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility_from_PROD.docx b/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility_from_PROD.docx new file mode 100644 index 000000000..46a1fce76 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility_from_PROD.docx differ diff --git a/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility_pre_prod.doc b/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility_pre_prod.doc new file mode 100644 index 000000000..5cba54cfe Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS27_ContinuousEligibility_pre_prod.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS28_PresumptiveEligibilityForChildren.doc b/services/ui-src/public/assets/docs/IG_CS28_PresumptiveEligibilityForChildren.doc new file mode 100644 index 000000000..421b202ac Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS28_PresumptiveEligibilityForChildren.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS29_PresumptiveEligibilityForPregnantWomen.doc b/services/ui-src/public/assets/docs/IG_CS29_PresumptiveEligibilityForPregnantWomen.doc new file mode 100644 index 000000000..9bfb54b14 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS29_PresumptiveEligibilityForPregnantWomen.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS3_MedicaidExpansion.doc b/services/ui-src/public/assets/docs/IG_CS3_MedicaidExpansion.doc new file mode 100644 index 000000000..d1a1375c5 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS3_MedicaidExpansion.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS7_TargetedLow-IncomeChildren.doc b/services/ui-src/public/assets/docs/IG_CS7_TargetedLow-IncomeChildren.doc new file mode 100644 index 000000000..91344563e Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS7_TargetedLow-IncomeChildren.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS8_TargetedLow-IncomePregnantWomen.doc b/services/ui-src/public/assets/docs/IG_CS8_TargetedLow-IncomePregnantWomen.doc new file mode 100644 index 000000000..d70ffbd9e Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS8_TargetedLow-IncomePregnantWomen.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CS9_CoverageFromConceptionToBirth.doc b/services/ui-src/public/assets/docs/IG_CS9_CoverageFromConceptionToBirth.doc new file mode 100644 index 000000000..83d16eb06 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CS9_CoverageFromConceptionToBirth.doc differ diff --git a/services/ui-src/public/assets/docs/IG_ChipEligibilityIntroduction.doc b/services/ui-src/public/assets/docs/IG_ChipEligibilityIntroduction.doc new file mode 100644 index 000000000..40af983f6 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_ChipEligibilityIntroduction.doc differ diff --git a/services/ui-src/public/assets/docs/IG_CostSharingBackground.doc b/services/ui-src/public/assets/docs/IG_CostSharingBackground.doc new file mode 100644 index 000000000..ee529ae72 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_CostSharingBackground.doc differ diff --git a/services/ui-src/public/assets/docs/IG_G1_CostSharingRequirements.doc b/services/ui-src/public/assets/docs/IG_G1_CostSharingRequirements.doc new file mode 100644 index 000000000..33b9a2160 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_G1_CostSharingRequirements.doc differ diff --git a/services/ui-src/public/assets/docs/IG_G2a_CostSharingAmountsCN.doc b/services/ui-src/public/assets/docs/IG_G2a_CostSharingAmountsCN.doc new file mode 100644 index 000000000..779de6e38 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_G2a_CostSharingAmountsCN.doc differ diff --git a/services/ui-src/public/assets/docs/IG_G2b_CostSharingAmountsMN.doc b/services/ui-src/public/assets/docs/IG_G2b_CostSharingAmountsMN.doc new file mode 100644 index 000000000..d185b14e2 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_G2b_CostSharingAmountsMN.doc differ diff --git a/services/ui-src/public/assets/docs/IG_G2c_CostSharingAmountsTargeting.doc b/services/ui-src/public/assets/docs/IG_G2c_CostSharingAmountsTargeting.doc new file mode 100644 index 000000000..474183322 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_G2c_CostSharingAmountsTargeting.doc differ diff --git a/services/ui-src/public/assets/docs/IG_G3_CostSharingLimitations.doc b/services/ui-src/public/assets/docs/IG_G3_CostSharingLimitations.doc new file mode 100644 index 000000000..d0dd79da5 Binary files /dev/null and b/services/ui-src/public/assets/docs/IG_G3_CostSharingLimitations.doc differ diff --git a/services/ui-src/public/assets/forms/ABP1.pdf b/services/ui-src/public/assets/forms/ABP1.pdf new file mode 100644 index 000000000..ebd7fa940 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP1.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP10.pdf b/services/ui-src/public/assets/forms/ABP10.pdf new file mode 100644 index 000000000..921cfe28d Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP10.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP11.pdf b/services/ui-src/public/assets/forms/ABP11.pdf new file mode 100644 index 000000000..4cfaac1e1 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP11.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP2a.pdf b/services/ui-src/public/assets/forms/ABP2a.pdf new file mode 100644 index 000000000..50d20f68d Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP2a.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP2b.pdf b/services/ui-src/public/assets/forms/ABP2b.pdf new file mode 100644 index 000000000..64b146cb1 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP2b.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP3.1.pdf b/services/ui-src/public/assets/forms/ABP3.1.pdf new file mode 100644 index 000000000..1e3caf220 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP3.1.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP3.pdf b/services/ui-src/public/assets/forms/ABP3.pdf new file mode 100644 index 000000000..11d516c07 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP3.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP4.pdf b/services/ui-src/public/assets/forms/ABP4.pdf new file mode 100644 index 000000000..226b4d2bc Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP4.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP5.pdf b/services/ui-src/public/assets/forms/ABP5.pdf new file mode 100644 index 000000000..024d7a267 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP5.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP6.pdf b/services/ui-src/public/assets/forms/ABP6.pdf new file mode 100644 index 000000000..a803f1e6b Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP6.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP7.pdf b/services/ui-src/public/assets/forms/ABP7.pdf new file mode 100644 index 000000000..7bbc07dc1 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP7.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP8.pdf b/services/ui-src/public/assets/forms/ABP8.pdf new file mode 100644 index 000000000..be7435215 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP8.pdf differ diff --git a/services/ui-src/public/assets/forms/ABP9.pdf b/services/ui-src/public/assets/forms/ABP9.pdf new file mode 100644 index 000000000..b78859ac9 Binary files /dev/null and b/services/ui-src/public/assets/forms/ABP9.pdf differ diff --git a/services/ui-src/public/assets/forms/CS10.pdf b/services/ui-src/public/assets/forms/CS10.pdf new file mode 100644 index 000000000..d20d4e503 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS10.pdf differ diff --git a/services/ui-src/public/assets/forms/CS11.pdf b/services/ui-src/public/assets/forms/CS11.pdf new file mode 100644 index 000000000..b884b2b68 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS11.pdf differ diff --git a/services/ui-src/public/assets/forms/CS12.pdf b/services/ui-src/public/assets/forms/CS12.pdf new file mode 100644 index 000000000..45e471e8b Binary files /dev/null and b/services/ui-src/public/assets/forms/CS12.pdf differ diff --git a/services/ui-src/public/assets/forms/CS13.pdf b/services/ui-src/public/assets/forms/CS13.pdf new file mode 100644 index 000000000..713309696 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS13.pdf differ diff --git a/services/ui-src/public/assets/forms/CS14.pdf b/services/ui-src/public/assets/forms/CS14.pdf new file mode 100644 index 000000000..216a3fb28 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS14.pdf differ diff --git a/services/ui-src/public/assets/forms/CS15.pdf b/services/ui-src/public/assets/forms/CS15.pdf new file mode 100644 index 000000000..7ac9495cf Binary files /dev/null and b/services/ui-src/public/assets/forms/CS15.pdf differ diff --git a/services/ui-src/public/assets/forms/CS16.pdf b/services/ui-src/public/assets/forms/CS16.pdf new file mode 100644 index 000000000..2574d1c42 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS16.pdf differ diff --git a/services/ui-src/public/assets/forms/CS17.pdf b/services/ui-src/public/assets/forms/CS17.pdf new file mode 100644 index 000000000..44e9e39f8 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS17.pdf differ diff --git a/services/ui-src/public/assets/forms/CS18.pdf b/services/ui-src/public/assets/forms/CS18.pdf new file mode 100644 index 000000000..08b67dfb8 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS18.pdf differ diff --git a/services/ui-src/public/assets/forms/CS19.pdf b/services/ui-src/public/assets/forms/CS19.pdf new file mode 100644 index 000000000..f45327d7f Binary files /dev/null and b/services/ui-src/public/assets/forms/CS19.pdf differ diff --git a/services/ui-src/public/assets/forms/CS20.pdf b/services/ui-src/public/assets/forms/CS20.pdf new file mode 100644 index 000000000..ceaa343e2 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS20.pdf differ diff --git a/services/ui-src/public/assets/forms/CS21.pdf b/services/ui-src/public/assets/forms/CS21.pdf new file mode 100644 index 000000000..aa57d586c Binary files /dev/null and b/services/ui-src/public/assets/forms/CS21.pdf differ diff --git a/services/ui-src/public/assets/forms/CS23.pdf b/services/ui-src/public/assets/forms/CS23.pdf new file mode 100644 index 000000000..e6f4376f4 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS23.pdf differ diff --git a/services/ui-src/public/assets/forms/CS24.pdf b/services/ui-src/public/assets/forms/CS24.pdf new file mode 100644 index 000000000..1f51a6633 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS24.pdf differ diff --git a/services/ui-src/public/assets/forms/CS27.pdf b/services/ui-src/public/assets/forms/CS27.pdf new file mode 100644 index 000000000..9d03b6327 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS27.pdf differ diff --git a/services/ui-src/public/assets/forms/CS28.pdf b/services/ui-src/public/assets/forms/CS28.pdf new file mode 100644 index 000000000..6114aab41 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS28.pdf differ diff --git a/services/ui-src/public/assets/forms/CS29.pdf b/services/ui-src/public/assets/forms/CS29.pdf new file mode 100644 index 000000000..0af5fb117 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS29.pdf differ diff --git a/services/ui-src/public/assets/forms/CS3.pdf b/services/ui-src/public/assets/forms/CS3.pdf new file mode 100644 index 000000000..034882eaf Binary files /dev/null and b/services/ui-src/public/assets/forms/CS3.pdf differ diff --git a/services/ui-src/public/assets/forms/CS7.pdf b/services/ui-src/public/assets/forms/CS7.pdf new file mode 100644 index 000000000..9a32880bd Binary files /dev/null and b/services/ui-src/public/assets/forms/CS7.pdf differ diff --git a/services/ui-src/public/assets/forms/CS8.pdf b/services/ui-src/public/assets/forms/CS8.pdf new file mode 100644 index 000000000..57fd68ab7 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS8.pdf differ diff --git a/services/ui-src/public/assets/forms/CS9.pdf b/services/ui-src/public/assets/forms/CS9.pdf new file mode 100644 index 000000000..7f7fb3535 Binary files /dev/null and b/services/ui-src/public/assets/forms/CS9.pdf differ diff --git a/services/ui-src/public/assets/forms/G1.pdf b/services/ui-src/public/assets/forms/G1.pdf new file mode 100644 index 000000000..8428247cb Binary files /dev/null and b/services/ui-src/public/assets/forms/G1.pdf differ diff --git a/services/ui-src/public/assets/forms/G2a.pdf b/services/ui-src/public/assets/forms/G2a.pdf new file mode 100644 index 000000000..9460a24ff Binary files /dev/null and b/services/ui-src/public/assets/forms/G2a.pdf differ diff --git a/services/ui-src/public/assets/forms/G2b.pdf b/services/ui-src/public/assets/forms/G2b.pdf new file mode 100644 index 000000000..2493929b7 Binary files /dev/null and b/services/ui-src/public/assets/forms/G2b.pdf differ diff --git a/services/ui-src/public/assets/forms/G2c.pdf b/services/ui-src/public/assets/forms/G2c.pdf new file mode 100644 index 000000000..b0937fd53 Binary files /dev/null and b/services/ui-src/public/assets/forms/G2c.pdf differ diff --git a/services/ui-src/public/assets/forms/G3.pdf b/services/ui-src/public/assets/forms/G3.pdf new file mode 100644 index 000000000..467f07332 Binary files /dev/null and b/services/ui-src/public/assets/forms/G3.pdf differ diff --git a/services/ui-src/src/App.test.js b/services/ui-src/src/App.test.js index 25c7f4e13..c1a3531d7 100644 --- a/services/ui-src/src/App.test.js +++ b/services/ui-src/src/App.test.js @@ -1,19 +1,56 @@ import React from "react"; import { act } from "react-dom/test-utils"; -import { render } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { Auth } from "aws-amplify"; import UserDataApi from "./utils/UserDataApi"; import IdleTimerWrapper from "./components/IdleTimerWrapper"; +import NotificationsApi from './utils/NotificationApi'; -import { App } from "./App"; +import App from "./App"; import { RESPONSE_CODE } from "cmscommonlib"; jest.mock("aws-amplify"); jest.mock("./utils/UserDataApi"); jest.mock("./components/IdleTimerWrapper"); +jest.mock('uuid', () => ({ + v4: jest.fn(() => '1234-5678-9012-3456'), // Return a fixed UUID for testing +})); + +// Mocking launchdarkly-react-client-sdk +jest.mock('launchdarkly-react-client-sdk', () => { + return { + withLDProvider: () => (Component) => (props) => , + useFlags: jest.fn(), // This will be a mock function + }; +}); + +jest.mock('./utils/NotificationApi', () => ({ + createUserNotifications: jest.fn(), + dismissUserNotifications: jest.fn(), + getActiveSystemNotifications: jest.fn() +})); + +const { useFlags } = require('launchdarkly-react-client-sdk'); + beforeEach(() => { + // fixing the 'window.matchMedia is not defined' error + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + jest.clearAllMocks(); // render does not work for idleTimer -- workaround @@ -43,26 +80,71 @@ beforeEach(() => { ], }); UserDataApi.setContactInfo.mockResolvedValue(RESPONSE_CODE.USER_EXISTS); -}); -it("renders without crashing", async () => { - render( - - - - ); - await act(async () => {}); + NotificationsApi.createUserNotifications.mockResolvedValue([ + { + sk: 'notification-1', + header: 'Test Notification', + body: 'This is a test notification.', + buttonLink: '/some-link', + buttonText: 'Click here', + }, + ]); }); -it("handles error on fetch", async () => { - Auth.currentAuthenticatedUser.mockImplementationOnce(() => { - throw "an Error"; +describe("App Component", () => { + afterEach(() => { + jest.clearAllMocks(); // Reset mocks after each test }); - render( - - - - ); - await act(async () => {}); -}); + it("renders without crashing", async () => { + useFlags.mockReturnValue({ mmdlNotification: false }); + render( + + + + ); + await act(async () => {}); + }); + + it("handles error on fetch", async () => { + useFlags.mockReturnValue({ mmdlNotification: false }); + Auth.currentAuthenticatedUser.mockImplementationOnce(() => { + throw "an Error"; + }); + + render( + + + + ); + await act(async () => {}); + }); + + it("renders NotificationBanner when flag set to true", async () => { + useFlags.mockReturnValue({ mmdlNotification: true }); + render( + + + + ); + await act(async () => {}); + + const notificationBanner = screen.getByTestId('notification-alert'); // Assuming the NotificationBanner uses role="banner" + expect(notificationBanner).toBeInTheDocument(); + }); + + it("does not render NotificationBanner when flag set to false", async () => { + useFlags.mockReturnValue({ mmdlNotification: false }); // Set to false to test for absence + render( + + + + ); + await act(async () => {}); + + const notificationBanner = screen.queryByTestId('notification-alert'); // This checks for absence + expect(notificationBanner).not.toBeInTheDocument(); // Expect it to not be in the document + }); + +}); \ No newline at end of file diff --git a/services/ui-src/src/App.tsx b/services/ui-src/src/App.tsx index 620042aa4..bb3ff8197 100644 --- a/services/ui-src/src/App.tsx +++ b/services/ui-src/src/App.tsx @@ -17,6 +17,11 @@ import { import IdleTimerWrapper from "./components/IdleTimerWrapper"; import { ConfirmationDialog } from "./components/ConfirmationDialog"; +import NotificationBanner from "./components/NotificationBanner"; +import NotificationsApi from "./utils/NotificationApi"; +import { LOCAL_STORAGE_USERNOTIFICATIONS } from "./utils/StorageKeys"; +import { useFlags} from 'launchdarkly-react-client-sdk'; + const DEFAULT_AUTH_STATE: Omit< AppContextValue, "setUserInfo" | "updatePhoneNumber" | "confirmAction" @@ -27,11 +32,13 @@ const DEFAULT_AUTH_STATE: Omit< userProfile: {}, userRole: null, userStatus: null, - activeTerritories: null, + activeTerritories: null }; -export function App() { +const App = () => { const [authState, setAuthState] = useState(DEFAULT_AUTH_STATE); + const [notificationState, setNotificationState] = useState(false); + const {mmdlNotification} = useFlags(); const [confirmationDialog, setConfirmationDialog] = useState<{ heading: string; acceptText: string; @@ -99,7 +106,6 @@ export function App() { role === "onemac-state-user" || role === "onemac-helpdesk" ) .toString(); - setAuthState({ ...DEFAULT_AUTH_STATE, isAuthenticating: false, @@ -132,7 +138,6 @@ export function App() { error ); } - setAuthState({ ...DEFAULT_AUTH_STATE, isAuthenticating: false, @@ -146,6 +151,68 @@ export function App() { setUserInfo(); }, [setUserInfo]); + useEffect(() => { + if (mmdlNotification !== undefined) { // Ensure the flag has been resolved + setNotificationState(mmdlNotification); + } + }, [mmdlNotification]); + + useEffect(()=>{ + (async ()=> { + try{ + if(notificationState && authState.isAuthenticated) { + // set the notifications: Needs to be stored locally to persist on reload + const email : any = authState.userProfile.email; + let userData : any = authState.userProfile.userData; + // Check local storage for notifications + const storedNotifications = localStorage.getItem( + LOCAL_STORAGE_USERNOTIFICATIONS + ); + if (storedNotifications !== undefined && storedNotifications?.length && storedNotifications.length > 2) { + userData.notifications = JSON.parse(storedNotifications); + } else { + // get the notifications & set local storage + const notifications = await NotificationsApi.createUserNotifications( + email + ); + if(notifications) { + userData.notifications = notifications; + localStorage.setItem( + LOCAL_STORAGE_USERNOTIFICATIONS, + JSON.stringify(notifications) + ); + // set authState userData notifications + setAuthState((prevState) => ({ + ...prevState, + userProfile: { + ...prevState.userProfile, + userData: { + ...prevState.userProfile?.userData, + notifications: notifications, + roleList: prevState.userProfile?.userData?.roleList ?? [], // typescript UserProfile type def needs a value + }, + }, + })); + } + } + } + } + catch (error) { + console.log( + "There was an error retreiving notifications.", + error + ); + } + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + },[notificationState, authState.isAuthenticated]) + + useEffect(() => { + // On initial load of the App, try to set the user info. + // It will capture info if they are logged in from a previous session. + setUserInfo(); + }, [setUserInfo]); + const { email, firstName, lastName, cmsRoles } = authState.userProfile; useEffect(() => { // When user's email or name changes, create a record of their info if it @@ -187,10 +254,23 @@ export function App() { [authState, setUserInfo, updatePhoneNumber, confirmAction] ); + const notifcations = useMemo(() => { + if (authState.userProfile.userData?.notifications) { + return authState.userProfile.userData?.notifications; + } else return []; + }, [authState.userProfile.userData]); + return authState.isAuthenticating ? null : (
+ {notificationState && notifcations.map((n) => ( + + ))}
@@ -217,3 +297,4 @@ export function App() { ); } +export default App; \ No newline at end of file diff --git a/services/ui-src/src/Routes.tsx b/services/ui-src/src/Routes.tsx index dc286fb97..8c349b191 100644 --- a/services/ui-src/src/Routes.tsx +++ b/services/ui-src/src/Routes.tsx @@ -4,6 +4,8 @@ import React, { FC } from "react"; import { Redirect, Route, Switch } from "react-router-dom"; +import jwt_decode from "jwt-decode"; +import { useFlags } from "launchdarkly-react-client-sdk"; import { ROUTES, @@ -49,10 +51,10 @@ import WaiverAppendixKWithdraw from "./page/waiver-appendix-k/WaiverAppendixKWit import WaiverAppendixKRAIForm from "./page/waiver-appendix-k/WaiverAppendixKRAIForm"; import DescribeForms from "./page/DescribeForms"; import EventList from "./page/event/EventList"; -import EventDetail from "./page/event/EventDetail"; import MedicaidABPLandingPage from "./page/landing/MedicaidABPLandingPage"; -import MedicaidEligibilityLandingPage from "./page/landing/MedicaidEligibilityLandingPage"; +import EventDetail from "./page/event/EventDetail"; import CHIPEligibilityLandingPage from "./page/landing/CHIPEligibilityLandingPage"; +import MedicaidEligibilityLandingPage from "./page/landing/MedicaidEligibilityLandingPage"; import InitialWaiverB4Form from "./page/initial-waiver/InitialWaiverB4Form"; import InitialWaiverBForm from "./page/initial-waiver/InitialWaiverBForm"; import WaiverRenewalB4Form from "./page/waiver-renewal/WaiverRenewalB4Form"; @@ -69,6 +71,7 @@ import WaiverRenewalSubsequentSubmissionForm from "./page/waiver-renewal/WaiverR import WaiverAmendmentSubsequentSubmissionForm from "./page/waiver-amendment/WaiverAmendmentSubsequentSubmissionForm"; import WaiverAppKSubsequentSubmissionForm from "./page/waiver-appendix-k/WaiverAppKSubsequentSubmissionForm"; import DisableRaiWithdrawForm from "./page/disable-rai-withdraw/DisableRaiWithdrawForm"; +const ID_TOKEN_KEY: string = "idToken"; type RouteSpec = { path: string; @@ -90,9 +93,22 @@ const RouteListRenderer: FC<{ routes: RouteSpec[] }> = ({ routes }) => { if (!useAppContext()?.isAuthenticated) { clearTableStateStorageKeys(); } + + const { enableSubsequentDocumentation } = useFlags(); + let filteredRoutes; + if (!enableSubsequentDocumentation) { + // Filter out objects where the component includes a SubsequentSubmission form + // This is not currently looking for subroutes since all subsub routes are at the root of the route object + filteredRoutes = routes.filter( + (route) => !route.path.includes("subsequent-submission") + ); + } else { + filteredRoutes = routes; + } + return ( - {routes.map((routeSpec) => ( + {filteredRoutes.map((routeSpec) => ( = ({ return ; }; +const isAdminUser = ()=> { + /* eslint-disable-next-line react-hooks/rules-of-hooks */ + const context = useAppContext(); + if(!context?.isAuthenticated) { + return false; + } + + let userRoles; + //authenticated users will have idToken in Local Storage + try{ + const idTokenKey: string[] = Object.keys(localStorage).filter((k) => + k.includes(ID_TOKEN_KEY) + ); + const idToken: string | null = + idTokenKey && localStorage.getItem(idTokenKey[0]); + if (!idToken) return false; + const decodedIdToken: any = jwt_decode(idToken); + userRoles = decodedIdToken["custom:user_roles"]; + } catch (error) { + console.error("error decoding idToken", error); + return false; + } + + const allowedRoles = [ + "cmsroleapprover", + "systemadmin", + "statesystemadmin", + "helpdesk", + "cmsreviewer", + // "defaultcmsuser" + ]; + + // only passes admin check if roles from jwt one of the "allowed roles" + if (userRoles) { + try { + userRoles = JSON.parse(userRoles); + } catch (error) { + console.error('Error parsing user_roles:', error); + userRoles = []; + } + for (let i = 0; i < userRoles.length; i++) { + if (allowedRoles.includes(userRoles[i])) { + return true; + } + } + } + + return false; +} + const accessGuardRouteListRenderer: ( accessKey: keyof UserRole, redirectAccessKey?: keyof UserRole, - redirectTo?: string + redirectTo?: string, + isAdminRoute?: boolean ) => FC<{ routes: RouteSpec[] }> = - (accessKey, redirectAccessKey, redirectTo) => + (accessKey, redirectAccessKey, redirectTo, isAdminRoute) => ({ routes }) => { const { userProfile: { userData: { roleList = [] } = {} } = {} } = useAppContext() ?? {}; const roleObj = getUserRoleObj(roleList); - + // Token based admin check will redirect if non admin user + if(isAdminRoute && redirectTo) { + if(!isAdminUser()){ + return ; + } + } if (roleObj[accessKey]) return ; if (redirectAccessKey && redirectTo && roleObj[redirectAccessKey]) return ; @@ -148,6 +220,7 @@ const ROUTE_LIST: RouteSpec[] = [ { path: ROUTES.HOME, exact: true, component: Home }, { path: ROUTES.FAQ, exact: true, component: FAQ }, { path: ROUTES.DEVLOGIN, exact: true, component: DevLogin }, + { path: ROUTES.PROFILE, component: AuthenticatedRouteListRenderer, @@ -161,7 +234,7 @@ const ROUTE_LIST: RouteSpec[] = [ path: ROUTES.PROFILE + "/:userId", exact: true, component: UserPage, - }, + } ], }, { @@ -184,6 +257,7 @@ const ROUTE_LIST: RouteSpec[] = [ accessKey: "canAccessUserManagement", redirectAccessKey: "canAccessDashboard", redirectTo: ONEMAC_ROUTES.PACKAGE_LIST, + isAdminRoute: true, component: UserManagement, }, { @@ -191,9 +265,10 @@ const ROUTE_LIST: RouteSpec[] = [ accessKey: "canAccessDashboard", redirectAccessKey: "canAccessUserManagement", redirectTo: ROUTES.USER_MANAGEMENT, + isAdminRoute: false, component: PackageList, }, - ].map(({ path, accessKey, redirectAccessKey, redirectTo, component }) => ({ + ].map(({ path, accessKey, redirectAccessKey, redirectTo, component, isAdminRoute }) => ({ path, component: SignupGuardRouteListRenderer, routes: [ @@ -202,11 +277,12 @@ const ROUTE_LIST: RouteSpec[] = [ component: accessGuardRouteListRenderer( accessKey as keyof UserRole, redirectAccessKey as keyof UserRole, - redirectTo + redirectTo, + isAdminRoute ), - routes: [{ path, exact: true, component }], + routes: [{ path, exact: true, component}], }, - ], + ] })), // legacy triage screens, plus current OneMACForm forms ...[ diff --git a/services/ui-src/src/components/IdleTimerWrapper.tsx b/services/ui-src/src/components/IdleTimerWrapper.tsx index 11950dff2..d1eb9edfc 100644 --- a/services/ui-src/src/components/IdleTimerWrapper.tsx +++ b/services/ui-src/src/components/IdleTimerWrapper.tsx @@ -15,8 +15,7 @@ const IdleTimerWrapper = () => { * the logout occurs on the front end before the backend to avoid sync issues */ - const { confirmAction, isAuthenticated, isLoggedInAsDeveloper } = - useAppContext() ?? {}; + const { confirmAction, isAuthenticated } = useAppContext() ?? {}; const [promptTimeout, setPromptTimeout] = useState(PROMPT_TIME); const [logoutTimeout, setLogoutTimeout] = useState(LOGOUT_TIME); @@ -66,7 +65,7 @@ const IdleTimerWrapper = () => { * this depends on promptTimeout and logoutTimeout * this is to ensure that the idleTimer has the most recent values for the times */ - if (isAuthenticated && !isLoggedInAsDeveloper) { + if (isAuthenticated) { setTimeoutTimes(); idleTimer.start(); } @@ -79,11 +78,13 @@ const IdleTimerWrapper = () => { const tokenKey: string[] = Object.keys(localStorage).filter((k) => k.includes(STORAGE_KEY) ); + const loginToken: string | null = tokenKey && localStorage.getItem(tokenKey[0]); if (!loginToken) return; const decodedToken: any = jwt_decode(loginToken); + const epochAuthTime: number | undefined = decodedToken?.auth_time; if (!epochAuthTime) return; diff --git a/services/ui-src/src/components/MACCard.tsx b/services/ui-src/src/components/MACCard.tsx index 951e035a8..710a0fb2d 100644 --- a/services/ui-src/src/components/MACCard.tsx +++ b/services/ui-src/src/components/MACCard.tsx @@ -47,6 +47,7 @@ export const MACCardWrapper = ({
{children && (
{ return ( -
- {title && } - {!isReadOnly && hasRoleAccess && renderIf && ( - - )} -
+ + {!isReadOnly && hasRoleAccess && renderIf && ( + + )} {description && {description}} {children}
diff --git a/services/ui-src/src/components/NotificationBanner.test.js b/services/ui-src/src/components/NotificationBanner.test.js new file mode 100644 index 000000000..cc17a71a5 --- /dev/null +++ b/services/ui-src/src/components/NotificationBanner.test.js @@ -0,0 +1,66 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import NotificationBanner from "./NotificationBanner"; + +// Mock the Alert component from @cmsgov/design-system +jest.mock("@cmsgov/design-system", () => ({ + Alert: ({ children, className }) => ( +
{children}
+ ), + Button: ({ children, className, href }) => ( + + {children} + + ), +})); + +describe("NotificationBanner", () => { + test("renders header and body", () => { + render( + + ); + + expect(screen.getByText(/Test Header/)).toBeInTheDocument(); + expect(screen.getByText(/Test body content/)).toBeInTheDocument(); + }); + + test("renders button if provided", () => { + render( + + ); + + const buttonElement = screen.getByText(/Click Me/); + expect(buttonElement).toBeInTheDocument(); + expect(buttonElement).toHaveAttribute("href", "http://example.com"); + }); + + test("does not render button if not provided", () => { + render( + + ); + + expect(screen.queryByText(/Click Me/)).toBeNull(); + }); + + test("closes the notification when the close button is clicked", () => { + render( + + ); + + // Ensure the notification is initially visible + expect(screen.getByText(/Test Header/)).toBeInTheDocument(); + + // Click the close button + const closeButton = screen.getByLabelText(/Dismiss alert/); + fireEvent.click(closeButton); + + // Ensure the notification is no longer visible + expect(screen.queryByText(/Test Header/)).toBeNull(); + }); +}); diff --git a/services/ui-src/src/components/NotificationBanner.tsx b/services/ui-src/src/components/NotificationBanner.tsx new file mode 100644 index 000000000..0f430e78d --- /dev/null +++ b/services/ui-src/src/components/NotificationBanner.tsx @@ -0,0 +1,70 @@ +import React, { useState } from "react"; +import { Alert, Button } from "@cmsgov/design-system"; +import closingX from "../images/AlertClosingX.svg"; +import { NotificationType } from "../domain-types"; +import NotificationApi from "../utils/NotificationApi"; +import { LOCAL_STORAGE_USERNOTIFICATIONS } from "../utils/StorageKeys"; + +type NotificationBannerProps = NotificationType & { + userEmail: string; +}; + +const CLOSING_X_IMAGE = ( + close-x +); + +const NotificationBanner: React.FC = ( + props: NotificationBannerProps +) => { + // notification state should be grabbed from context + const [showNotification, setShowNotification] = useState(true); + + // closing banner invokes API call + const close = () => { + (async () => { + const dissmissed = await NotificationApi.dismissUserNotifications( + props.userEmail, + props.sk + ); + console.log("dissmissed emails: ", dissmissed); + })(); + localStorage.removeItem(LOCAL_STORAGE_USERNOTIFICATIONS); + setShowNotification(false); + }; + + if (!showNotification) return <>; + return ( + +
+ {/* TODO: put in flex, make button black */} +
+

{props.header}

+

{props.body}

+
+
+ {props.buttonLink && props.buttonText && ( + + )} + + +
+
+
+ ); +}; + +export default NotificationBanner; diff --git a/services/ui-src/src/components/NotificationCard.test.js b/services/ui-src/src/components/NotificationCard.test.js new file mode 100644 index 000000000..b240e26e8 --- /dev/null +++ b/services/ui-src/src/components/NotificationCard.test.js @@ -0,0 +1,65 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import { NotificationCard } from "./NotificationCard"; + +describe("NotificationCard", () => { + test("renders header and body", () => { + render( + + ); + + expect(screen.getByText(/Test Header:/)).toBeInTheDocument(); + expect(screen.getByText(/Test body content/)).toBeInTheDocument(); + }); + + test("renders link if provided", () => { + render( + + ); + + const linkElement = screen.getByText(/Example Link/); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute("href", "http://example.com"); + }); + + test("does not render link when link prop is not provided", () => { + render( + + ); + + expect(screen.queryByText(/Example Link/)).toBeNull(); + }); + + test("renders MACCard with correct props", () => { + const { container } = render( + + ); + + // Check that MACCard is rendered + expect(container.querySelector(".mac-card-wrapper")).toBeInTheDocument(); + + // Ensure MACCard has the correct props + const macCardChildElement = screen.getByTestId("MACCard-children"); + expect(macCardChildElement).toHaveClass("home-content-full-width"); + expect(macCardChildElement).toHaveClass("mac-card-border"); + }); +}); diff --git a/services/ui-src/src/components/NotificationCard.tsx b/services/ui-src/src/components/NotificationCard.tsx new file mode 100644 index 000000000..d70ccbccb --- /dev/null +++ b/services/ui-src/src/components/NotificationCard.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { MACCard } from "./MACCard"; +import { ExternalLinkIcon } from "@cmsgov/design-system"; +import { formatDateOnly } from "../utils/date-utils"; +import { NotificationType } from "../domain-types"; + +export const NotificationCard = (props: NotificationType) => { + const date = formatDateOnly(props.publicationDate); + return ( + + {props.header}: + {props.body}{" "} + {props.buttonLink && props.buttonText && ( + + {props.buttonText} + + )}{" "} + {props.buttonLink && } {date} + + ); +}; diff --git a/services/ui-src/src/containers/FAQ.test.js b/services/ui-src/src/containers/FAQ.test.js index 0d696bcfc..03a9e3a03 100644 --- a/services/ui-src/src/containers/FAQ.test.js +++ b/services/ui-src/src/containers/FAQ.test.js @@ -3,43 +3,105 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import FAQ from "./FAQ"; -it("has target _blank for the external link", () => { - render(); - - expect( - screen - .getByText("42 C.F.R. §430.12.", { selector: "a" }) - .getAttribute("target") - ).toBe("_blank"); +// Mocking the uuid package +jest.mock('uuid', () => ({ + v4: jest.fn(() => '1234-5678-9012-3456'), // Return a fixed UUID for testing +})); + +// Mocking launchdarkly-react-client-sdk +jest.mock('launchdarkly-react-client-sdk', () => { + return { + withLDProvider: () => (Component) => (props) => , + useFlags: jest.fn(), // This will be a mock function + }; }); -it("expands linked question", async () => { - render(); +const { useFlags } = require('launchdarkly-react-client-sdk'); + +describe("FAQ Component", () => { + afterEach(() => { + jest.clearAllMocks(); // Reset mocks after each test + }); - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.location.href = - window.location.href + "#waiverb-extension-attachments"; + it("has target _blank for the external link", () => { + useFlags.mockReturnValue({ mmdlFaq: false }); + render(); - const btnEl = await screen.findByRole("button", { - name: "What are the attachments for a 1915(b) Waiver - Request for Temporary Extension?", + expect( + screen.getAllByText("42 C.F.R. §430.12.", { selector: "a" })[0] + .getAttribute("target") + ).toBe("_blank"); }); - await waitFor(() => { - expect(btnEl).toHaveAttribute("aria-expanded", "true"); + + it("expands linked question", async () => { + useFlags.mockReturnValue({ mmdlFaq: false }); + render(); + + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + window.location.href += "#waiverb-extension-attachments"; + + const btnEl = await screen.findByRole("button", { + name: "What are the attachments for a 1915(b) Waiver - Request for Temporary Extension?", + }); + await waitFor(() => { + expect(btnEl).toHaveAttribute("aria-expanded", "true"); + }); }); -}); -it("expand button opens all", async () => { - render(); + it("expand button opens all", async () => { + useFlags.mockReturnValue({ mmdlFaq: false }); + render(); + + const btnEl = await screen.findByRole("button", { + name: "Expand all to search with CTRL+F", + }); + const btnEl2 = await screen.findByRole("button", { + name: "What are the attachments for a 1915(b) Waiver - Request for Temporary Extension?", + }); - const btnEl = await screen.findByRole("button", { - name: "Expand all to search with CTRL+F", + userEvent.click(btnEl); + await waitFor(() => { + expect(btnEl2).toHaveAttribute("aria-expanded", "true"); + }); }); - const btnEl2 = await screen.findByRole("button", { - name: "What are the attachments for a 1915(b) Waiver - Request for Temporary Extension?", + + it("links to a new browser tab that opens a pdf", async () => { + useFlags.mockReturnValue({ mmdlFaq: true }); + render(); + const linkEl = await screen.getByText( + "CS 7: Eligibility - Targeted Low-Income Children" + ); + expect(linkEl).toHaveAttribute("href", "/assets/forms/CS7.pdf"); + expect(linkEl).toHaveAttribute("target", "_blank"); + expect(linkEl).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("links to a download of a .doc file", async () => { + useFlags.mockReturnValue({ mmdlFaq: true }); + render(); + const linkEl = await screen.getByText( + "G 1: Cost-Sharing Requirements Implementation Guide" + ); + expect(linkEl).toHaveAttribute( + "href", + "/assets/docs/IG_G1_CostSharingRequirements.doc" + ); + expect(linkEl).toHaveAttribute("download"); }); - userEvent.click(btnEl); - await waitFor(() => { - expect(btnEl2).toHaveAttribute("aria-expanded", "true"); + it("renders no mmdl related FAQ questions when mmdlFaq set to false", async () => { + useFlags.mockReturnValue({ mmdlFaq: false }); + render(); + const accordionItems = screen.getAllByRole('button'); + expect(accordionItems.length).toBe(36); // Adjust based on expected outcome + }); + + it("renders mmdl related FAQ questions when mmdlFaq set to true", async () => { + useFlags.mockReturnValue({ mmdlFaq: true }); + render(); + const accordionItems = screen.getAllByRole('button'); + expect(accordionItems.length).toBe(43); // Adjust based on expected count }); }); + + diff --git a/services/ui-src/src/containers/FAQ.tsx b/services/ui-src/src/containers/FAQ.tsx index 76fec7449..86b839eb6 100644 --- a/services/ui-src/src/containers/FAQ.tsx +++ b/services/ui-src/src/containers/FAQ.tsx @@ -9,6 +9,7 @@ import { import { Accordion, AccordionItem, Button } from "@cmsgov/design-system"; import { MACCard } from "../components/MACCard"; +import { useFlags} from 'launchdarkly-react-client-sdk'; /** Refactored out for later extraction by cms-ux-lib. However, using this * abstraction rather than doing it inline as we do in the FAQ return created @@ -41,6 +42,8 @@ export const FAQSection = ({ section }: { section: FAQContent }) => { }; const FAQ = () => { + + const {mmdlFaq} = useFlags() const [faqItems, setFaqItems] = useState(oneMACFAQContent); const [hash, setHash] = useState(window.location.hash.replace("#", "")); @@ -135,25 +138,47 @@ const FAQ = () => { /** To be replaced with {@link FAQSection} */

{section.sectionTitle}

- - {section.qanda.map((questionAnswer, i) => ( -
- - toggleAccordianItem(questionAnswer.anchorText) - } - > - {questionAnswer.answerJSX} - -
-
- ))} -
+ {mmdlFaq ? + + {section.qanda.map((questionAnswer, i) => ( +
+ + toggleAccordianItem(questionAnswer.anchorText) + } + > + {questionAnswer.answerJSX} + +
+
+ ))} +
: + + {section.qanda.map((questionAnswer, i) => ( + !questionAnswer.isMmdl && +
+ + toggleAccordianItem(questionAnswer.anchorText) + } + > + {questionAnswer.answerJSX} + +
+
+ ))} +
+ }
))}
@@ -170,4 +195,6 @@ const FAQ = () => { ); }; + export default FAQ; + diff --git a/services/ui-src/src/containers/Home.js b/services/ui-src/src/containers/Home.js index d468acd6e..97b4cab4a 100644 --- a/services/ui-src/src/containers/Home.js +++ b/services/ui-src/src/containers/Home.js @@ -1,10 +1,12 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; import HomeHeader from "../components/HomeHeader"; import HomeFooter from "../components/HomeFooter"; import AlertBar from "../components/AlertBar"; import { MACCard } from "../components/MACCard"; - +import NotificationApi from "../utils/NotificationApi"; +import { NotificationCard } from "../components/NotificationCard"; +import { useFlags} from 'launchdarkly-react-client-sdk'; const stateSubmissionTitle = "How to create a submission"; const stateSubmissionsList = [ { @@ -149,11 +151,38 @@ const renderPaperSubmissionInfo = (renderSubmissionSteps) => { */ export default function Home() { const location = useLocation(); + const {mmdlNotification} = useFlags() + const [systemNotifications, setSystemNotifications] = useState([]); + + useEffect(()=> { + (async()=>{ + if(mmdlNotification) { + const notifications = + await NotificationApi.getActiveSystemNotifications(); + if (notifications && notifications.length) + setSystemNotifications([...notifications]); + else { + console.log( + "Either no notifications or an error occured", + notifications + ); + } + } + })(); + },[mmdlNotification]) return ( <> + {mmdlNotification && systemNotifications.length !== 0 && ( +
+

New and Notable

+ {systemNotifications.map((notification) => ( + + ))} +
+ )}

State Users

diff --git a/services/ui-src/src/containers/Home.test.js b/services/ui-src/src/containers/Home.test.js index 77b52b1ee..b6af49d54 100644 --- a/services/ui-src/src/containers/Home.test.js +++ b/services/ui-src/src/containers/Home.test.js @@ -1,13 +1,67 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { render, screen, waitFor, act } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import NotificationApi from "../utils/NotificationApi"; import Home from "./Home"; -it("renders without crashing", () => { - render( - - - - ); +jest.mock("../utils/NotificationApi"); + + +jest.mock('uuid', () => ({ + v4: jest.fn(() => '1234-5678-9012-3456'), // Return a fixed UUID for testing +})); + +// Mocking launchdarkly-react-client-sdk +jest.mock('launchdarkly-react-client-sdk', () => { + return { + withLDProvider: () => (Component) => (props) => , + useFlags: jest.fn(), // This will be a mock function + }; +}); + +jest.mock('../utils/NotificationApi', () => ({ + getActiveSystemNotifications: jest.fn() +})); + +const { useFlags } = require('launchdarkly-react-client-sdk'); + +it("renders without crashing", async () => { + useFlags.mockReturnValue({ mmdlNotification: true }); + NotificationApi.getActiveSystemNotifications.mockResolvedValue([ + { header: "heading", body: "Test Notification" }, + ]); + + await act(async () => { + render( + + + + ); + }); + + // Wait for notifications to be processed and verify the output + await waitFor(() => { + expect(NotificationApi.getActiveSystemNotifications).toHaveBeenCalled(); + }); }); + +it("does not render notifications component when mmdlNotification set to false", async () => { + useFlags.mockReturnValue({ mmdlNotification: false }); + + + await act(async () => { + render( + + + + ); + }); + + const notificationBanner = screen.queryByTestId('notification-card'); // This checks for absence + expect(notificationBanner).not.toBeInTheDocument(); + // Wait for notifications to be processed and verify the output + await waitFor(() => { + expect(NotificationApi.getActiveSystemNotifications).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/services/ui-src/src/containers/PackageList.js b/services/ui-src/src/containers/PackageList.js index fc23ff21d..0c7a377e4 100644 --- a/services/ui-src/src/containers/PackageList.js +++ b/services/ui-src/src/containers/PackageList.js @@ -218,7 +218,7 @@ const PackageList = () => { ({ value, row }) => ( {value} diff --git a/services/ui-src/src/containers/UserManagement.js b/services/ui-src/src/containers/UserManagement.js index 328e63ca7..1cba6f28b 100644 --- a/services/ui-src/src/containers/UserManagement.js +++ b/services/ui-src/src/containers/UserManagement.js @@ -166,7 +166,7 @@ const UserManagement = () => { ({ value, row }) => ( {value} diff --git a/services/ui-src/src/containers/UserPage.js b/services/ui-src/src/containers/UserPage.js index fff126682..011497c34 100644 --- a/services/ui-src/src/containers/UserPage.js +++ b/services/ui-src/src/containers/UserPage.js @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { useLocation, useParams } from "react-router-dom"; +import { useLocation, useParams, useHistory } from "react-router-dom"; import { Button, Review } from "@cmsgov/design-system"; import { @@ -157,6 +157,7 @@ export const GroupDivisionDisplay = ({ profileData = {} }) => { * Component housing data belonging to a particular user */ const UserPage = () => { + const history = useHistory(); const { userProfile, setUserInfo, updatePhoneNumber, userRole, userStatus } = useAppContext(); const location = useLocation(); @@ -174,10 +175,10 @@ const UserPage = () => { const [isEditingPhone, setIsEditingPhone] = useState(false); const isReadOnly = location.pathname !== ROUTES.PROFILE && - decodeURIComponent(userId) !== userProfile.email; + decodeURIComponent(userId) !== window.btoa(userProfile.email); useEffect(() => { - const getProfile = async (profileEmail) => { + const getProfile = async (encodedProfileEmail) => { if (!isReadOnly) { return [{ ...userProfile.userData }, userRole, userStatus]; } @@ -187,6 +188,7 @@ const UserPage = () => { tempProfileStatus = "status"; try { + const profileEmail = window.atob(encodedProfileEmail); tempProfileData = await UserDataApi.userProfile(profileEmail); const profileAccess = effectiveRoleForUser(tempProfileData?.roleList); if (profileAccess !== null) @@ -194,6 +196,8 @@ const UserPage = () => { } catch (e) { console.error("Error fetching user data", e); setAlertCode(RESPONSE_CODE[e.message]); + // redirect if the user is not found + history.push("/notfound"); } return [tempProfileData, tempProfileRole, tempProfileStatus]; @@ -209,7 +213,7 @@ const UserPage = () => { console.error("Error fetching user data", e); setAlertCode(RESPONSE_CODE[e.message]); }); - }, [isReadOnly, userId, userProfile, userRole, userStatus]); + }, [isReadOnly, userId, userProfile, userRole, userStatus, history]); const onPhoneNumberCancel = useCallback(() => { setIsEditingPhone(false); diff --git a/services/ui-src/src/domain-types.ts b/services/ui-src/src/domain-types.ts index 99e406352..e6f1be1b2 100644 --- a/services/ui-src/src/domain-types.ts +++ b/services/ui-src/src/domain-types.ts @@ -1,5 +1,19 @@ import { USER_STATUS, USER_ROLE, RoleEntry } from "cmscommonlib"; +export type NotificationType = { + pk: string; + sk: string; + notificationId: string; + header: string; + body: string; + buttonText?: string; + buttonLink?: string; + publicationDate: Date; + expiryDate?: string; + activeStatus: boolean; + notificationType: string; +}; + export type UserProfile = { ismemberof: string; cmsRoles: string; @@ -13,9 +27,10 @@ export type UserRecord = { roleList: RoleEntry[]; validRoutes?: string[]; phoneNumber?: string; - fullName: string; + fullName?: string; effRole?: string; effStatus?: string; + notifications?: NotificationType[]; }; export type AccessHistoryEvent = { diff --git a/services/ui-src/src/index.scss b/services/ui-src/src/index.scss index 8acfc89d3..9307cd74e 100644 --- a/services/ui-src/src/index.scss +++ b/services/ui-src/src/index.scss @@ -1745,6 +1745,8 @@ article.form-container { right: 300px; bottom: 400px; height: 100%; + pointer-events: none; + z-index: -1; } } @@ -1892,6 +1894,10 @@ article.form-container { } } + .mac-card-wrapper:has(.home-content-full-width) { + width: 100%; + } + .mac-card-wrapper { width: 40%; } @@ -1942,6 +1948,14 @@ article.form-container { @extend .ds-u-measure--base; } + .home-content-full-width { + @extend .ds-l-lg-col--12; + @extend .ds-u-lg-margin-right--3; + @extend .ds-u-margin-bottom--5; + @extend .ds-u-lg-margin-bottom--0; + @extend .ds-u-padding-bottom--5; + } + .home-content-left-box { @extend .ds-l-lg-col--5; @extend .ds-u-lg-margin-right--3; @@ -2145,6 +2159,14 @@ article.form-container { padding-left: 0; } +.spa-link { + text-decoration: none; +} + +.list-item-with-bullet { + list-style: disc; +} + .file-type-list-item { display: flex; b { @@ -2960,4 +2982,8 @@ article.form-container { } .inversed-accordion-button > svg { display: none; -} \ No newline at end of file +} + +.notification-alert .ds-c-alert__body { + width: 100%; +} diff --git a/services/ui-src/src/index.tsx b/services/ui-src/src/index.tsx index 3a5a4a102..94d340f02 100644 --- a/services/ui-src/src/index.tsx +++ b/services/ui-src/src/index.tsx @@ -10,14 +10,17 @@ import "core-js/es/object"; import "isomorphic-fetch"; import "rsuite/dist/rsuite.min.css"; import "./index.scss"; -import { App } from "./App"; +import App from "./App"; import { BrowserRouter } from "react-router-dom"; import { Amplify } from "aws-amplify"; import { getApplicationNode } from "./utils"; import config from "./utils/config"; import { ONEMAC_ROUTES } from "cmscommonlib"; import "core-js/stable"; +import { withLDProvider } from 'launchdarkly-react-client-sdk'; +const clientId = process.env.REACT_APP_LD_CLIENT_ID; +// Amplify configuration let amplifyConfig = { Auth: { mandatorySignIn: true, @@ -51,9 +54,20 @@ let amplifyConfig = { Amplify.configure(amplifyConfig); +// Wrap your App component with withLDProvider +const LDProviderApp = withLDProvider({ + clientSideID: clientId ?? "undefined", // Make sure this is set correctly + options: { + // @ts-ignore + streamUrl: "https://clientstream.launchdarkly.us", + baseUrl: "https://clientsdk.launchdarkly.us", + eventsUrl: "https://events.launchdarkly.us", + } +})(App); + ReactDOM.render( - + , getApplicationNode() -); +); \ No newline at end of file diff --git a/services/ui-src/src/libs/faq/faqContent.tsx b/services/ui-src/src/libs/faq/faqContent.tsx index c4e8fecbd..94283a9bf 100644 --- a/services/ui-src/src/libs/faq/faqContent.tsx +++ b/services/ui-src/src/libs/faq/faqContent.tsx @@ -10,6 +10,7 @@ export interface QuestionAnswer { isOpen: boolean; question: string; answerJSX: JSX.Element; + isMmdl: boolean; } export interface FAQContent { @@ -28,6 +29,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "system", isOpen: false, question: "Which system should I use for my state’s submission?", + isMmdl: false, answerJSX: ( <>

@@ -52,6 +54,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "browsers", isOpen: false, question: "What browsers can I use to access the system?", + isMmdl: false, answerJSX: (

The submission portal works best on Google Chrome (Version @@ -63,6 +66,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "confirm-email", isOpen: false, question: "What should we do if we don’t receive a confirmation email?", + isMmdl: false, answerJSX: (

Refresh your inbox, check your SPAM filters, then contact the OneMAC @@ -78,6 +82,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "is-official", isOpen: false, question: "Is this considered the official state submission?", + isMmdl: false, answerJSX: (

Yes, as long as you have the electronic receipt (confirmation @@ -95,6 +100,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "onemac-roles", isOpen: false, question: "What are the OneMAC user roles?", + isMmdl: false, answerJSX: ( @@ -142,6 +148,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "acceptable-file-formats", isOpen: false, question: "What are the kinds of file formats I can upload into OneMAC", + isMmdl: false, answerJSX: (

@@ -166,6 +173,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "onboarding-materials", isOpen: false, question: "Onboarding Materials", + isMmdl: false, answerJSX: ( <>

    @@ -208,6 +216,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "spa-id-format", isOpen: false, question: "What format is used to enter a SPA ID?", + isMmdl: false, answerJSX: ( <> Enter the State Plan Amendment transmittal number. Assign @@ -232,6 +241,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "medicaid-spa-attachments", isOpen: false, question: "What are the attachments for a Medicaid SPA?", + isMmdl: false, answerJSX: ( <>

    @@ -333,6 +343,7 @@ export const oneMACFAQContent: FAQContent[] = [ isOpen: false, question: "What are the attachments for a Medicaid response to Request for Additional Information (RAI)?", + isMmdl: false, answerJSX: ( <>

    Note: “*” indicates a required attachment.

    @@ -367,6 +378,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "chip-spa-attachments", isOpen: false, question: "What are the attachments for a CHIP SPA?", + isMmdl: false, answerJSX: ( <>

    Note: “*” indicates a required attachment.

    @@ -437,6 +449,7 @@ export const oneMACFAQContent: FAQContent[] = [ isOpen: false, question: "What are the attachments for a CHIP SPA response to Request for Additional Information (RAI)?", + isMmdl: false, answerJSX: ( <>

    Note: “*” indicates a required attachment.

    @@ -502,6 +515,7 @@ export const oneMACFAQContent: FAQContent[] = [ isOpen: false, question: "Can I submit SPAs relating to the Public Health Emergency (PHE) in OneMAC?", + isMmdl: false, answerJSX: (

    Yes, all PHE-related SPAs should be submitted through OneMAC by @@ -514,6 +528,7 @@ export const oneMACFAQContent: FAQContent[] = [ isOpen: false, question: "How do I submit a Formal Request for Additional Information (RAI) Response for a Medicaid SPA?", + isMmdl: false, answerJSX: (

    @@ -559,6 +574,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "medicaid-spa-withdraw-rai-response", isOpen: false, question: "How do I Withdraw a Formal RAI Response for a Medicaid SPA?", + isMmdl: false, answerJSX: (

    @@ -615,6 +631,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "withdraw-medicaid-spa", isOpen: false, question: "How do I Withdraw a Package for a Medicaid SPA?", + isMmdl: false, answerJSX: (

    @@ -656,6 +673,7 @@ export const oneMACFAQContent: FAQContent[] = [ isOpen: false, question: "How do I submit a Formal Request for Additional Information (RAI) Response for a CHIP SPA?", + isMmdl: false, answerJSX: (

    @@ -701,6 +719,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "chip-spa-withdraw-rai-response", isOpen: false, question: "How do I Withdraw a Formal RAI Response for a CHIP SPA?", + isMmdl: false, answerJSX: (

    @@ -757,6 +776,7 @@ export const oneMACFAQContent: FAQContent[] = [ anchorText: "withdraw-chip-spa", isOpen: false, question: "How do I Withdraw a Package for a CHIP SPA?", + isMmdl: false, answerJSX: (

    @@ -793,6 +813,393 @@ export const oneMACFAQContent: FAQContent[] = [

    ), }, + { + anchorText: "which-spa-submitted", + isOpen: false, + question: "What State Plan Amendments (SPAs) can be submitted to OneMAC", + isMmdl: true, + answerJSX: ( +
    +

    + OneMAC is the electronic submission system for all paper-based Medicaid and Children's Health insurance Program (CHIP) SPAs. +

    +

    + Starting [TBD], CMS is modifying the state transmittal process for Medicaid. + Alternative Benefit Plan SPAs, Medicaid Premium and Cost Sharing SPAs, and CHIP Eligibility SPAs which were previously submitted to CMS through Medicaid Model Data Lab (MMDL). +

    +

    + In order to be processed, States will need to submit these state actions to the OneMAC system. The MMDL system will no longer accept new submissions for these SPA actions. +

    +

    + MMDL SPA submissions submitted prior to [TBD], including those SPA actions currently off the clock with a Request for Additional Information; will continue to be processed through the MMDL system. + However, no new submissions or amendments will be accepted in the MMDL system. +

    +

    + Medicaid Alternative Benefit SPAs, Premium and Cost Sharing SPAs, and CHIP Eligibility SPAs will continue to use the published SPA templates and Implementation Guides which can now be found here/below. This change does not apply to electronic Medicaid SPA submissions processed in the Medicaid and CHIP Program Portal (MACPro) system. +

    +
    + ), + }, + { + anchorText: "medicaid-alternative-benifit-plan-pdfs", + isOpen: false, + question: + "Where can I download Medicaid Alternative Benefit Plan (ABP) SPA PDFs?", + isMmdl: true, + answerJSX: ( + <> +
      + {[ + ["ABP1.pdf", "ABP 1: Alternative Benefit Plan Populations"], + ["ABP2a.pdf", "ABP 2a: Voluntary Benefit Package Selection Assurances - Eligibility Group under Section 1902(a)(10)(A)(i)(VIII) of the Act"], + ["ABP2b.pdf", "ABP 2b: Voluntary Enrollment Assurances for Eligibility Groups other than the Adult Group under Section 1902(a)(10)(A)(i)(VIII) of the Act"], + ["ABP3.pdf", "ABP 3: Selection of Benchmark Benefit Package or Benchmark-Equivalent Benefit Package Use only if ABP has an effective date earlier than 1/1/2020 or if only changing the Section 1937 Coverage Option of an ABP implemented before 1/1/2020"], + ["ABP3.1.pdf", "ABP 3.1: Selection of Benchmark Benefit or Benchmark-Equivalent Benefit Package Use only for ABP's effective on or after 1/1/2020"], + ["ABP4.pdf", "ABP 4: Alternative Benefit Plan Cost Sharing"], + ["ABP5.pdf", "ABP 5: Benefits Description"], + ["ABP6.pdf", "ABP 6: Benchmark-Equivalent Benefit Package"], + ["ABP7.pdf", "ABP 7: Benefits Assurances"], + ["ABP8.pdf", "ABP 8: Service Delivery Systems"], + ["ABP9.pdf", "ABP 9: Employer-Sponsored Insurance and Payment of Premiums"], + ["ABP10.pdf", "ABP 10: General Assurances"], + ["ABP11.pdf", "ABP 11: Payment Methodology"], + ].map(([filename, label]) => ( !label.includes("or Benchmark-Equivalent Benefit Package") && !label.includes("3.1") ? +
    • + + {label} + +
    • : label.includes("3.1") ? +
    • +
      + + {label.substring(0,79)} + +
        +
      • + {label.substring(79)} +
      • +
      +
      +
    • : +
    • +
      + + + {label.substring(0,85)} + +
        +
      • + {label.substring(85)} +
      • +
      +
      +
    • + ))} +
    + + ), + }, + { + anchorText: "medicaid-alternative-benifit-plan-implimention-guides", + isOpen: false, + question: + "Where can I download Medicaid Alternative Benefit Plan (ABP) implementation guides?", + isMmdl: true, + answerJSX: ( + <> +
      + {[ + ["IG_ABP1_AlternativeBenefitPlanPopulations.doc", "ABP 1: Alternative Benefit Plan Populations Implementation Guide"], + ["IG_ABP2a_VoluntaryBenefitPackageAssurances.doc", "ABP 2a: Voluntary Benefit Package Selection Assurances - Eligibility Group under Section 1902(a)(10)(A)(i)(VIII) of the Act Implementation Guide"], + ["IG_ABP2b_VoluntaryEnrollmentAssurances.doc", "ABP 2b: Voluntary Enrollment Assurances for Eligibility Groups other than the Adult Group under Section 1902(a)(10)(A)(i)(VIII) of the Act Implementation Guide"], + ["IG_ABP3_SelectionOfBenchmark.doc", "ABP 3: Selection of Benchmark Benefit Package or Benchmark-Equivalent Benefit Package Implementation Guide"], + ["IG_ABP3.1_SelectionOfBenchmark20190819-Final.docx", "ABP 3.1: Selection of Benchmark Benefit Package or Benchmark-Equivalent Benefit Package Implementation Guide"], + ["IG_ABP4_AbpCostSharing.doc", "ABP 4: Alternative Benefit Plan Cost Sharing Implementation Guide"], + ["IG_ABP5_BenefitsDescription-Final.docx", "ABP 5: Benefits Description Implementation Guide"], + ["IG_ABP6_BenchmarkEquivalentBenefit.doc", "ABP 6: Benchmark-Equivalent Benefit Package Implementation Guide"], + ["IG_ABP7_BenefitAssurances.doc", "ABP 7: Benefit Assurances Implementation Guide"], + ["IG_ABP8_ServiceDeliverySystems.doc", "ABP 8:Service Delivery Systems Implementation Guide"], + ["IG_ABP9_EmployerSponsoredInsurance.doc", "ABP 9: Employer-Sponsored Insurance and Payment of Premiums Implementation Guide"], + ["IG_ABP10_GeneralAssurances.doc", "ABP 10: General Assurances Implementation Guide"], + ["IG_ABP11_PaymentMethodology.doc", "ABP 11: Payment Methodology Implementation Guide"], + ].map(([filename, label]) => ( +
    • + + {label} + +
    • + ))} +
    + + ), + }, + { + anchorText: "medicaid-premiums-cost-sharing-pdfs", + isOpen: false, + isMmdl: true, + question: + "Where can I download Medicaid Premiums and Cost Sharing (P&CS) SPA PDFs?", + answerJSX: ( + <> +
      + {[ + ["G1.pdf", "G 1: Cost Sharing Requirements"], + ["G2a.pdf", "G 2a: Cost Sharing Amounts - Categorically Needy"], + ["G2b.pdf", "G 2b: Cost Sharing Amounts - Medically Needy"], + ["G2c.pdf", "G 2c: Cost Sharing Amounts - Targeting"], + ["G3.pdf", "G 3: Cost Sharing Limitations"], + ].map(([filename, label]) => ( +
    • + + {label} + +
    • + ))} +
    + + ), + }, + { + anchorText: "medicaid-premiums-cost-sharing-inplementation guides", + isOpen: false, + isMmdl: true, + question: + "Where can I download Medicaid Premiums and Cost Sharing (P&CS) SPA implementation guides?", + answerJSX: ( + <> +
      + {[ + ["IG_G1_CostSharingRequirements.doc", "G 1: Cost-Sharing Requirements Implementation Guide"], + ["IG_G2a_CostSharingAmountsCN.doc", "Cost Sharing Amounts - Categorically Needy Implementation Guide"], + ["IG_G2b_CostSharingAmountsMN.doc", "Cost Sharing Amounts - Medically Needy Implementation Guide"], + ["IG_G2c_CostSharingAmountsTargeting.doc", "Cost Sharing Amounts - Targeting Implementation Guide"], + ["IG_G3_CostSharingLimitations.doc", "Cost Sharing Limitations Implementation Guide"], + ].map(([filename, label]) => ( +
    • + + {label} + +
    • + ))} +
    + + ), + }, + { + anchorText: "chip-spa-pdfs", + isOpen: false, + isMmdl: true, + question: + "Where can I download CHIP SPA PDFs?", + answerJSX: ( + <> +

    MAGI Eligibility & Methods

    +
      + {[ + ["CS7.pdf", "CS 7: Eligibility - Targeted Low-Income Children"], + ["CS8.pdf", "CS 8: Eligibility - Targeted Low-Income Pregnant Women"], + ["CS9.pdf", "CS 9: Eligibility - Coverage From Conception to Birth"], + ["CS10.pdf", "CS 10: Eligibility - Children Who Have Access to Public Employee Coverage"], + ["CS11.pdf", "CS 11: Eligibility - Pregnant Women Who Have Access to Public Employee Coverage"], + ["CS12.pdf", "CS 12: Eligibility - Dental Only Supplemental Coverage"], + ["CS13.pdf", "CS 13: Eligibility - Deemed Newborns"], + ["CS15.pdf", "CS 15: MAGI-Based Income Methodologies"], + ["CS16.pdf", "CS 16: Other Eligibility Criteria - Spenddowns"], + ].map(([filename, label]) => ( +
    • + + {label} + +
    • + ))} +
    +

    XXI Medicaid Expansion

    + + "CS 3: Eligibility for Medicaid Expansion Program" + +

    Establish 2101(f) Groups

    + + "CS 14: Eligibility - Children Ineligible for Medicaid as a Result of the + Elimination of Income Disregards" + +

    Eligibility Processing

    + + "CS 24: General Eligibility - Eligibility Processing" + +

    Non-Financial Eligibility

    +
      + {[ + ["CS17.pdf", "Non-Financial Eligibility - Residency"], + ["CS18.pdf", "Non-Financial Eligibility - Citizenship"], + ["CS19.pdf", "Non-Financial Eligibility - Social Security Number"], + ["CS20.pdf", "Non-Financial Eligibility - Substitution of Coverage"], + ["CS21.pdf", "Non-Financial Eligibility - Non-Payment of Premiums"], + ["CS23.pdf", "Non-Financial Requirements - Other Eligibility Standards"], + ["CS27.pdf", "General Eligibility - Continuous Eligibility"], + ["CS28.pdf", "General Eligibility - Presumptive Eligibility for Children"], + ["CS29.pdf", "General Eligibility - Presumptive Eligibility for Pregnant Women"], + ].map(([filename, label]) => ( +
    • + + {label} + +
    • + ))} +
    + + ), + }, + { + anchorText: "chip-spa-implimentation-guides", + isOpen: false, + isMmdl: true, + question: + "Where can I download CHIP SPA implementation guides?", + answerJSX: ( + <> +

    MAGI Eligibility & Methods

    +
      + {[ + ["IG_CS7_TargetedLow-IncomeChildren.doc", "CS 7: Eligibility - Targeted Low-Income Children Implementation Guide"], + ["IG_CS8_TargetedLow-IncomePregnantWomen.doc", "CS 8: Eligibility - Targeted Low-Income Pregnant Women Implementation Guide"], + ["IG_CS9_CoverageFromConceptionToBirth.doc", "CS 9: Eligibility - Coverage From Conception to Birth Implementation Guide"], + ["IG_CS10_ChildrenWhoHaveAccessToPublicEmployeeCoverage.doc", "CS 10: Eligibility - Children Who Have Access to Public Employee Coverage Implementation Guide"], + ["IG_CS11_PregnantWomenWhoHaveAccessToPublicEmployeeCoverage.doc", "CS 11: Eligibility - Pregnant Women Who Have Access to Public Employee Coverage Implementation Guide"], + ["IG_CS12_DentalOnlySupplementalCoverage.doc", "CS 12: Eligibility - Dental Only Supplemental Coverage Implementation Guide"], + ["IG_CS13_DeemedNewborns.doc", "CS 13: Eligibility - Deemed Newborns Implementation Guide"], + ["IG_CS15_MAGI-BasedIncomeMethodologies.doc", "CS 15: MAGI-Based Income Methodologies Implementation Guide"], + ["IG_CS16_Spenddown.doc", "CS 16: Other Eligibility Criteria - Spenddowns Implementation Guide"], + ].map(([filename, label]) => ( +
    • + + {label} + +
    • + ))} +
    +

    XXI Medicaid Expansion

    + + CS 3: Eligibility for Medicaid Expansion Program Implementation Guide + +

    Establish 2101(f) Groups

    + + CS 14: Eligibility - Children Ineligible for Medicaid as a Result of the + Elimination of Income Disregards Implementation Guide + +

    Eligibility Processing

    + + CS 24: General Eligibility - Eligibility Processing Implementation Guide + +

    Non-Financial Eligibility

    +
      + {[ + ["IG_CS17_Non-Financial-Residency.doc", "CS 17: Non-Financial Eligibility - Residency Implementation Guide"], + ["IG_CS18_Non-Financial-Citizenship.doc", "CS 18: Non-Financial Eligibility - Citizenship Implementation Guide"], + ["IG_CS19_Non-Financial-SocialSecurityNumber.doc", "CS 19: Non-Financial Eligibility - Social Security Number Implementation Guide"], + ["IG_CS20_Non-Financial-SubstitutionOfCoverage.doc", "CS 20: Non-Financial Eligibility - Substitution of Coverage Implementation Guide"], + ["IG_CS21_NonFinancialNonPaymentOfPremiums.doc", "CS 21: Non-Financial Eligibility - Non-Payment of Premiums Implementation Guide"], + ["IG_CS23_NonFinancialRequirementOtherEligibilityStandards.doc", "CS 23: Non-Financial Requirements - Other Eligibility Standards Implementation Guide"], + ["IG_CS27_ContinuousEligibility.doc", "CS 27: General Eligibility - Continuous Eligibility Implementation Guide"], + ["IG_CS28_PresumptiveEligibilityForChildren.doc", "CS 28: General Eligibility - Presumptive Eligibility for Children Implementation Guide"], + ["IG_CS29_PresumptiveEligibilityForPregnantWomen.doc", "CS 29: General Eligibility - Presumptive Eligibility for Pregnant Women Implementation Guide"], + ].map(([filename, label]) => ( +
    • + + {label} + +
    • + ))} +
    + + ), + }, ], }, { @@ -801,6 +1208,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "initial-waiver-id-format", isOpen: false, + isMmdl: false, question: "What format is used to enter a 1915(b) Initial Waiver number?", answerJSX: ( @@ -828,6 +1236,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiver-renewal-id-format", isOpen: false, + isMmdl: false, question: "What format is used to enter a 1915(b) Waiver Renewal number?", answerJSX: ( @@ -855,6 +1264,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiver-amendment-id-format", isOpen: false, + isMmdl: false, question: "What format is used to enter a 1915(b) Waiver Amendment number?", answerJSX: ( @@ -883,6 +1293,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiver-id-help", isOpen: false, + isMmdl: false, question: "Who can I contact to help me figure out the correct 1915(b) Waiver Number?", answerJSX: ( @@ -898,6 +1309,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiver-c-id", isOpen: false, + isMmdl: false, question: "What format is used to enter a 1915(c) waiver number?", answerJSX: ( <> @@ -928,6 +1340,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiverb-attachments", isOpen: false, + isMmdl: false, question: "What attachments are needed to submit a 1915(b) waiver action?", answerJSX: ( @@ -1012,6 +1425,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiverb-rai-attachments", isOpen: false, + isMmdl: false, question: "What are the attachments for a 1915(b) Waiver response to Request for Additional Information (RAI)?", answerJSX: ( @@ -1049,6 +1463,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiver-extension-id-format", isOpen: false, + isMmdl: false, question: "What format is used to enter a 1915(b) and 1915(c) Temporary Extension number?", answerJSX: ( @@ -1083,6 +1498,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiver-extension-status", isOpen: false, + isMmdl: false, question: "Why does the status of my Temporary Extension Request continue to show as 'Submitted'?", answerJSX: ( @@ -1099,6 +1515,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiverb-extension-attachments", isOpen: false, + isMmdl: false, question: "What are the attachments for a 1915(b) Waiver - Request for Temporary Extension?", answerJSX: ( @@ -1135,6 +1552,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiverc-extension-attachments", isOpen: false, + isMmdl: false, question: "What are the attachments for a 1915(c) Waiver - Request for Temporary Extension", answerJSX: ( @@ -1171,6 +1589,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "appk", isOpen: false, + isMmdl: false, question: "Can I submit Appendix K amendments in OneMAC?", answerJSX: (

    @@ -1182,6 +1601,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "appk-attachments", isOpen: false, + isMmdl: false, question: "What are the attachments for a 1915(c) Appendix K Waiver?", answerJSX: ( <> @@ -1221,6 +1641,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiver-rai-response", isOpen: false, + isMmdl: false, question: "How do I submit a Formal Request for Additional Information (RAI) Response for a Waiver?", answerJSX: ( @@ -1267,6 +1688,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "waiver-withdraw-rai-response", isOpen: false, + isMmdl: false, question: "How do I Withdraw a Formal RAI Response for a Medicaid Waiver?", answerJSX: ( @@ -1324,6 +1746,7 @@ export const oneMACFAQContent: FAQContent[] = [ { anchorText: "withdraw-waiver", isOpen: false, + isMmdl: false, question: "How do I Withdraw a Package for a Waiver?", answerJSX: (

    diff --git a/services/ui-src/src/libs/formLib.tsx b/services/ui-src/src/libs/formLib.tsx index f961bfd5f..307a92258 100644 --- a/services/ui-src/src/libs/formLib.tsx +++ b/services/ui-src/src/libs/formLib.tsx @@ -67,11 +67,10 @@ export const DefaultFileTypesInfo = () => (

    ); - export const DefaultFileTypesInfoSubSub = () => (

    - We accept the following file formats: .docx, .jpg, .pdf, .png, .xlsx, and more:{" "} - See the full list on the{" "} + We accept the following file formats: .docx, .jpg, .pdf, .png, .xlsx,{" "} + and more: See the full list on the{" "} FAQ Page @@ -79,7 +78,6 @@ export const DefaultFileTypesInfoSubSub = () => (

    ); - export const DefaultFileSizeInfo = ({ route }: { route: string }) => (

    Maximum file size of {config.MAX_ATTACHMENT_SIZE_MB} MB per attachment.{" "} @@ -196,9 +194,7 @@ export const defaultConfirmSubmitMessageSubsequentSubmission = ( export const defaultConfirmSubsequentSubmission: ConfirmSubmitType = { confirmSubmitHeading: "Submit additional documents?", confirmSubmitMessage: ( -

    - These documents will be added to the package and reviewed by CMS. -

    +

    These documents will be added to the package and reviewed by CMS.

    ), }; diff --git a/services/ui-src/src/setupTests.js b/services/ui-src/src/setupTests.js index 97d6d79fb..7b6f79bb9 100644 --- a/services/ui-src/src/setupTests.js +++ b/services/ui-src/src/setupTests.js @@ -16,4 +16,4 @@ jest.mock("focus-trap", () => { unpause: () => {}, }; return () => trap; -}); \ No newline at end of file +}); diff --git a/services/ui-src/src/utils/NotificationApi.test.js b/services/ui-src/src/utils/NotificationApi.test.js new file mode 100644 index 000000000..5a3ed1ecf --- /dev/null +++ b/services/ui-src/src/utils/NotificationApi.test.js @@ -0,0 +1,131 @@ +import NotificationApi from "./NotificationApi"; +import { API } from "aws-amplify"; +import handleApiError from "../libs/apiErrorHandler"; + +jest.mock("aws-amplify"); +jest.mock("../libs/apiErrorHandler"); + +describe("NotificationApi", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getActiveSystemNotifications", () => { + it("should fetch active system notifications", async () => { + const mockNotifications = [{ id: 1, message: "System update available" }]; + API.get.mockResolvedValue(mockNotifications); + + const notifications = + await NotificationApi.getActiveSystemNotifications(); + + expect(notifications).toEqual(mockNotifications); + expect(API.get).toHaveBeenCalledWith( + "oneMacAPI", + "/getActiveSystemNotifications", + {} + ); + }); + + it("should handle errors when fetching notifications", async () => { + const mockError = new Error("API error"); + API.get.mockRejectedValue(mockError); + handleApiError.mockReturnValue("Error handling"); + + const result = await NotificationApi.getActiveSystemNotifications(); + + expect(result).toBe("Error handling"); + expect(handleApiError).toHaveBeenCalledWith( + mockError, + "NOTIFICATION_RETRIEVAL_ERROR", + "There was an error fetching data for the system notifications." + ); + }); + }); + + describe("createUserNotifications", () => { + it("should create user notifications", async () => { + const mockNotifications = [{ id: 2, message: "New message received" }]; + API.post.mockResolvedValue(mockNotifications); + + const notifications = await NotificationApi.createUserNotifications(); + + expect(notifications).toEqual(mockNotifications); + expect(API.post).toHaveBeenCalledWith( + "oneMacAPI", + "/createUserNotifications", + {} + ); + }); + + it("should handle errors when creating notifications", async () => { + const mockError = new Error("API error"); + API.post.mockRejectedValue(mockError); + handleApiError.mockReturnValue("Error handling"); + + const result = await NotificationApi.createUserNotifications(); + + expect(result).toBe("Error handling"); + expect(handleApiError).toHaveBeenCalledWith( + mockError, + "NOTIFICATION_RETRIEVAL_ERROR", + "There was an error fetching data for the users notifications." + ); + }); + }); + + describe("dismissUserNotifications", () => { + const userEmail = "test@example.com"; + const notificationId = "123"; + + it("should dismiss user notifications", async () => { + const mockNotifications = [{ id: 3, message: "Notification dismissed" }]; + API.patch.mockResolvedValue(mockNotifications); + + const notifications = await NotificationApi.dismissUserNotifications( + userEmail, + notificationId + ); + + expect(notifications).toEqual(mockNotifications); + expect(API.patch).toHaveBeenCalledWith( + "oneMacAPI", + `/dismissNotification/${userEmail}/${notificationId}`, + {} + ); + }); + + it("should throw an error if userEmail is not specified", async () => { + await expect( + NotificationApi.dismissUserNotifications("", notificationId) + ).rejects.toThrow( + "user Email was not specified for notification API call" + ); + }); + + it("should throw an error if notificationId is not specified", async () => { + await expect( + NotificationApi.dismissUserNotifications(userEmail, "") + ).rejects.toThrow( + "notification Id was not specified for notification API call" + ); + }); + + it("should handle errors when dismissing notifications", async () => { + const mockError = new Error("API error"); + API.patch.mockRejectedValue(mockError); + handleApiError.mockReturnValue("Error handling"); + + const result = await NotificationApi.dismissUserNotifications( + userEmail, + notificationId + ); + + expect(result).toBe("Error handling"); + expect(handleApiError).toHaveBeenCalledWith( + mockError, + "NOTIFICATION_DISSMISS_ERROR", + "There was an error dismissing the users notifications." + ); + }); + }); +}); diff --git a/services/ui-src/src/utils/NotificationApi.ts b/services/ui-src/src/utils/NotificationApi.ts new file mode 100644 index 000000000..c790a7a03 --- /dev/null +++ b/services/ui-src/src/utils/NotificationApi.ts @@ -0,0 +1,101 @@ +import { API } from "aws-amplify"; +import handleApiError from "../libs/apiErrorHandler"; +import { NotificationType } from "../domain-types"; + +/** + * Singleton class to retrieve System and User Notifications + */ +class NotificationApi { + /** + * Gets all the Active System Notifications + * Throws an exception if the API throws an exception + * @param none + * @return a list of current System Notifications + */ + async getActiveSystemNotifications(): Promise { + try { + const notifications = await API.get( + "oneMacAPI", + `/getActiveSystemNotifications`, + {} + ); + return notifications; + } catch (error) { + return handleApiError( + error, + "NOTIFICATION_RETRIEVAL_ERROR", + "There was an error fetching data for the system notifications." + ); + } + } + + /** + * Gets all the Active User Notifications + * Throws an exception if the API throws an exception + * @param {string} userEmail the user's email used as UserId + * @return a list of current User Notifications + */ + async createUserNotifications( + userEmail: string + ): Promise { + try { + const notifications = await API.post( + "oneMacAPI", + `/createUserNotifications/${userEmail}`, + {} + ); + return notifications.body.notifications; + } catch (error) { + return handleApiError( + error, + "NOTIFICATION_RETRIEVAL_ERROR", + "There was an error fetching data for the users notifications." + ); + } + } + + /** + * Dismisses specific user notifications + * Throws an exception if the API throws an exception + * @param {string} userEmail the user's email used as UserId + * @param {string} notificationId to find the specific notification to dismiss + * @return a list of current User Notifications + */ + async dismissUserNotifications( + userEmail: string, + notificationId: string + ): Promise { + // check for correct params + if (!userEmail) { + console.log("user Email was not specified for notification API call"); + throw new Error("user Email was not specified for notification API call"); + } + if (!notificationId) { + console.log( + "notification Id was not specified for notification API call" + ); + throw new Error( + "notification Id was not specified for notification API call" + ); + } + + // try to dismiss the notication + try { + return await API.patch( + "oneMacAPI", + `/dismissNotification/${userEmail}/${notificationId}`, + {} + ); + } catch (error) { + return handleApiError( + error, + "NOTIFICATION_DISSMISS_ERROR", + "There was an error dismissing the users notifications." + ); + } + } +} +const instance = new NotificationApi(); +if (process.env.NODE_ENV !== "test") Object.freeze(instance); + +export default instance; diff --git a/services/ui-src/src/utils/StorageKeys.ts b/services/ui-src/src/utils/StorageKeys.ts index 9d11b56b1..99ca370ac 100644 --- a/services/ui-src/src/utils/StorageKeys.ts +++ b/services/ui-src/src/utils/StorageKeys.ts @@ -10,6 +10,7 @@ export const LOCAL_STORAGE_FILTER_CHIP_STATE_SPA = "onemac-SPA-filterChipSavedState"; export const LOCAL_STORAGE_FILTER_CHIP_STATE_WAIVER = "onemac-WAIVER-filterChipSavedState"; +export const LOCAL_STORAGE_USERNOTIFICATIONS = "onemac-userNotifications"; export const clearTableStateStorageKeys = () => { // Remove all from localStorage @@ -19,6 +20,7 @@ export const clearTableStateStorageKeys = () => { localStorage.removeItem(LOCAL_STORAGE_COLUMN_VISIBILITY_WAIVER); localStorage.removeItem(LOCAL_STORAGE_FILTER_CHIP_STATE_SPA); localStorage.removeItem(LOCAL_STORAGE_FILTER_CHIP_STATE_WAIVER); + localStorage.removeItem(LOCAL_STORAGE_USERNOTIFICATIONS); // Remove session on logout, too sessionStorage.removeItem(LOCAL_STORAGE_TABLE_FILTERS_SPA); sessionStorage.removeItem(LOCAL_STORAGE_TABLE_FILTERS_WAIVER); diff --git a/tests/cypress/cypress/e2e/Profile_View_Mixed_Case_Emails.spec.feature b/tests/cypress/cypress/e2e/Profile_View_Mixed_Case_Emails.spec.feature deleted file mode 100644 index 7c2de541f..000000000 --- a/tests/cypress/cypress/e2e/Profile_View_Mixed_Case_Emails.spec.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: OY2-11159 Case sensitive emails causing login error - Scenario: Verify user is able to login with all lowercase and manipulate url for email to mixedcase and all uppercase characters - Given I am on Login Page - When Clicking on Development Login - When Login with "an Active" "State Submitter" user - Then i am on Dashboard Page - Then navigate to "/profile/statesubmitter@nightwatch.test" - Then Actual Full Name is Displayed - Then navigate to "/profile/STATESUBMITTER@NIGHTWATCH.TEST" - Then Actual Full Name is Displayed - Then navigate to "/profile/staTEsubmiTTeR@nightwatch.test" - Then Actual Full Name is Displayed \ No newline at end of file diff --git a/tests/cypress/cypress/e2e/Profile_View_Remove_Email_From_URL.spec.feature b/tests/cypress/cypress/e2e/Profile_View_Remove_Email_From_URL.spec.feature new file mode 100644 index 000000000..52ea60be3 --- /dev/null +++ b/tests/cypress/cypress/e2e/Profile_View_Remove_Email_From_URL.spec.feature @@ -0,0 +1,15 @@ +Feature: Encode profile urls + Scenario: Verify entering email in profile url shows page not found + Given I am on Login Page + When Clicking on Development Login + When Login with "an Active" "State Submitter" user + Then i am on Dashboard Page + Then navigate to "/profile/statesubmitter@nightwatch.test" + Then verify the dashboard says Sorry, page not found! + Then verify page url contains 'notfound' + Then navigate to "/profile/STATESUBMITTER@NIGHTWATCH.TEST" + Then verify the dashboard says Sorry, page not found! + Then verify page url contains 'notfound' + Then navigate to "/profile/staTEsubmiTTeR@nightwatch.test" + Then verify the dashboard says Sorry, page not found! + Then verify page url contains 'notfound' \ No newline at end of file diff --git a/tests/cypress/cypress/e2e/common/steps.js b/tests/cypress/cypress/e2e/common/steps.js index 11a79777a..e79689fb5 100644 --- a/tests/cypress/cypress/e2e/common/steps.js +++ b/tests/cypress/cypress/e2e/common/steps.js @@ -883,6 +883,9 @@ Then( Then("verify Error message displayed should be No Results Found", () => { OneMacDashboardPage.noResultsFoundErrorMessage(); }); +Then("verify the dashboard says Sorry, page not found!", () => { + OneMacDashboardPage.verifySorryPageNotFoundMessage(); +}); Then("verify user exists with id number searched", () => { cy.fixture("packageDashboardWaiverNumbers.json").then((data) => { OneMacDashboardPage.verifyIDNumberExists(data.newInitialWaiverNumber2); diff --git a/tests/cypress/support/pages/oneMacDashboardPage.js b/tests/cypress/support/pages/oneMacDashboardPage.js index e4860c9e6..fd7c4f776 100644 --- a/tests/cypress/support/pages/oneMacDashboardPage.js +++ b/tests/cypress/support/pages/oneMacDashboardPage.js @@ -348,6 +348,10 @@ export class oneMacDashboardPage { cy.xpath(noResultsFound).contains("No Results Found"); } + verifySorryPageNotFoundMessage() { + cy.get("h3").contains("Sorry, page not found!"); + } + typeCreatedIDNumber(s) { cy.get(searchbar).type(s); } diff --git a/tests/cypress/support/pages/oneMacFormPage.js b/tests/cypress/support/pages/oneMacFormPage.js index b12b52cd4..8f79034d6 100644 --- a/tests/cypress/support/pages/oneMacFormPage.js +++ b/tests/cypress/support/pages/oneMacFormPage.js @@ -12,8 +12,8 @@ const modalCancelBTN = "//*[@id='react-aria-modal-dialog']//button[text()='Cancel']"; const attachmentInfoDescription = "//h3[text()='Attachments']/following-sibling::p[1]"; -const enterMmdlBtn = "//button[contains(text(),'Enter the MMDL system')]"; const enterMacProBtn = "//button[contains(text(),'Enter the MACPro system')]"; +const enterMmdlBtn = "//button[contains(text(),'Enter the MMDL system')]"; const IDInputBox = idElement; const errorMessageID = "#componentIdStatusMsg0"; const errorMessageLine2ID = "#componentIdStatusMsg1"; @@ -293,6 +293,7 @@ export class oneMacFormPage { "https://wms-mmdl.cms.gov/MMDL/faces/portal.jsp" ); } + verifyMacProSystemBtn() { cy.xpath(enterMacProBtn).should("be.visible"); cy.xpath(enterMacProBtn)