diff --git a/src/libs/opensearch-lib.ts b/src/libs/opensearch-lib.ts index 09cc8ad37f..f5ba0dc66f 100644 --- a/src/libs/opensearch-lib.ts +++ b/src/libs/opensearch-lib.ts @@ -117,4 +117,40 @@ export async function getItem(host:string, index:string, id:string){ } catch(e) { console.log({e}) } +} + +export async function createIndexIfNotExists(host:string, index:string) { + client = client || (await getClient(host)); + try { + const indexExists = await client.indices.exists({ index, }); + if (!indexExists.body) { + const createResponse = await client.indices.create({ + index, + }); + + console.log('Index created:', createResponse); + } else { + console.log('Index already exists.'); + } + } catch (error) { + console.error('Error creating index:', error); + throw error; + } +} + +export async function updateFieldMapping(host:string, index:string, properties: object) { + client = client || (await getClient(host)); + try { + const response = await client.indices.putMapping({ + index: index, + body: { + properties, + }, + }); + + console.log('Field mapping updated:', response); + } catch (error) { + console.error('Error updating field mapping:', error); + throw error; + } } \ No newline at end of file diff --git a/src/packages/shared-types/action-types/withdraw-record.ts b/src/packages/shared-types/action-types/withdraw-record.ts index f42ad9a7b4..9e99c9cd7c 100644 --- a/src/packages/shared-types/action-types/withdraw-record.ts +++ b/src/packages/shared-types/action-types/withdraw-record.ts @@ -3,5 +3,4 @@ import { z } from "zod"; export const withdrawRecordSchema = z.object({ raiWithdrawEnabled: z.boolean(), }); - export type WithdrawRecord = z.infer; diff --git a/src/packages/shared-types/actions.ts b/src/packages/shared-types/actions.ts index 1b89727b6d..acb121be08 100644 --- a/src/packages/shared-types/actions.ts +++ b/src/packages/shared-types/actions.ts @@ -2,4 +2,5 @@ export enum Action { ENABLE_RAI_WITHDRAW = "enable-rai-withdraw", DISABLE_RAI_WITHDRAW = "disable-rai-withdraw", ISSUE_RAI = "issue-rai", + RESPOND_TO_RAI = "respond-to-rai", } diff --git a/src/packages/shared-types/onemac.ts b/src/packages/shared-types/onemac.ts index 56d59b7f29..7ad853ff65 100644 --- a/src/packages/shared-types/onemac.ts +++ b/src/packages/shared-types/onemac.ts @@ -11,13 +11,88 @@ const onemacAttachmentSchema = z.object({ key: z.string().nullish(), uploadDate: z.number().nullish(), }); +export type OnemacAttachmentSchema = z.infer; + +export const raiSchema = z.object({ + id: z.string(), + requestedDate: z.number(), + responseDate: z.number().nullish(), + withdrawnDate: z.number().nullish(), + attachments: z.array(onemacAttachmentSchema).nullish(), + additionalInformation: z.string().nullable().default(null), + submitterName: z.string(), + submitterEmail: z.string(), +}); +export type RaiSchema = z.infer; + +export interface RaiData { + [key: number]: { + requestedDate?: string; + responseDate?: string; + withdrawnDate?: string; + response?: { + additionalInformation: string; + submitterName: string | null; + submitterEmail: string | null; + attachments: any[] | null; // You might want to specify the type of attachments + }; + request?: { + additionalInformation: string; + submitterName: string | null; + submitterEmail: string | null; + attachments: any[] | null; // You might want to specify the type of attachments + }; + }; +} + +export const transformRaiIssue = (id: string) => { + return raiSchema.transform((data) => ({ + id, + rais: { + [data.requestedDate]: { + request: { + attachments: + data.attachments?.map((attachment) => { + return handleAttachment(attachment); + }) ?? null, + additionalInformation: data.additionalInformation, + submitterName: data.submitterName, + submitterEmail: data.submitterEmail, + }, + }, + }, + })); +}; +export type RaiIssueTransform = z.infer>; + +export const transformRaiResponse = (id: string) => { + return raiSchema.transform((data) => ({ + id, + rais: { + [data.requestedDate]: { + response: { + attachments: + data.attachments?.map((attachment) => { + return handleAttachment(attachment); + }) ?? null, + additionalInformation: data.additionalInformation, + submitterName: data.submitterName, + submitterEmail: data.submitterEmail, + }, + }, + }, + })); +}; +export type RaiResponseTransform = z.infer< + ReturnType +>; export const onemacSchema = z.object({ additionalInformation: z.string().nullable().default(null), submitterName: z.string(), submitterEmail: z.string(), attachments: z.array(onemacAttachmentSchema).nullish(), - raiWithdrawEnabled: z.boolean().optional(), + raiWithdrawEnabled: z.boolean().default(false), raiResponses: z .array( z.object({ @@ -30,75 +105,76 @@ export const onemacSchema = z.object({ }); export const transformOnemac = (id: string) => { - return onemacSchema.transform((data) => ({ - id, - attachments: - data.attachments?.map((attachment) => { - // this is a legacy onemac attachment - let bucket = ""; - let key = ""; - let uploadDate = 0; - if ("bucket" in attachment) { - bucket = attachment.bucket as string; - } - if ("key" in attachment) { - key = attachment.key as string; - } - if ("uploadDate" in attachment) { - uploadDate = attachment.uploadDate as number; - } - if (bucket == "") { - const parsedUrl = s3ParseUrl(attachment.url || ""); - if (!parsedUrl) return null; - bucket = parsedUrl.bucket; - key = parsedUrl.key; - uploadDate = parseInt(attachment.s3Key?.split("/")[0] || "0"); - } - - return { - title: attachment.title, - filename: attachment.filename, - uploadDate, - bucket, - key, + return onemacSchema.transform((data) => { + const transformedData = { + id, + attachments: + data.attachments?.map((attachment) => { + return handleAttachment(attachment); + }) ?? null, + raiWithdrawEnabled: data.raiWithdrawEnabled, + additionalInformation: data.additionalInformation, + submitterEmail: data.submitterEmail, + submitterName: data.submitterName === "-- --" ? null : data.submitterName, + origin: "oneMAC", + rais: {} as RaiData, + }; + if (data.raiResponses) { + data.raiResponses.forEach((raiResponse, index) => { + // We create an rai keyed off the index, because we don't know which rai it was in response to. Best we can do. + transformedData["rais"][index] = { + responseDate: raiResponse.submissionTimestamp.toString(), + response: { + additionalInformation: raiResponse.additionalInformation || "", + submitterName: null, + submitterEmail: null, + attachments: + raiResponse.attachments?.map((attachment) => { + return handleAttachment(attachment); + }) ?? null, + }, }; - }) ?? null, - raiWithdrawEnabled: data.raiWithdrawEnabled, - raiResponses: - data.raiResponses?.map((response) => { - return { - additionalInformation: response.additionalInformation, - submissionTimestamp: response.submissionTimestamp, - attachments: - response.attachments?.map((attachment) => { - const uploadDate = parseInt( - attachment.s3Key?.split("/")[0] || "0" - ); - const parsedUrl = s3ParseUrl(attachment.url || ""); - if (!parsedUrl) return null; - const { bucket, key } = parsedUrl; - - return { - ...attachment, - uploadDate, - bucket, - key, - }; - }) ?? null, - }; - }) ?? null, - additionalInformation: data.additionalInformation, - submitterEmail: data.submitterEmail, - submitterName: data.submitterName === "-- --" ? null : data.submitterName, - origin: "oneMAC", - })); + }); + } + return transformedData; + }); }; export type OneMacSink = z.infer; export type OneMacTransform = z.infer>; export type OneMacRecordsToDelete = Omit< { - [Property in keyof OneMacTransform]: undefined; + [Property in keyof OneMacTransform]: null; }, - "id" + "id" | "rais" > & { id: string }; + +function handleAttachment(attachment: OnemacAttachmentSchema) { + let bucket = ""; + let key = ""; + let uploadDate = 0; + if ("bucket" in attachment) { + bucket = attachment.bucket as string; + } + if ("key" in attachment) { + key = attachment.key as string; + } + if ("uploadDate" in attachment) { + uploadDate = attachment.uploadDate as number; + } + if (bucket == "") { + const parsedUrl = s3ParseUrl(attachment.url || ""); + if (!parsedUrl) return null; + bucket = parsedUrl.bucket; + key = parsedUrl.key; + uploadDate = parseInt(attachment.s3Key?.split("/")[0] || "0"); + } + + return { + title: attachment.title, + filename: attachment.filename, + uploadDate, + bucket, + key, + }; +} diff --git a/src/packages/shared-types/opensearch.ts b/src/packages/shared-types/opensearch.ts index a392a6eda1..104c2dff18 100644 --- a/src/packages/shared-types/opensearch.ts +++ b/src/packages/shared-types/opensearch.ts @@ -1,5 +1,5 @@ import { SeaToolTransform } from "./seatool"; -import { OneMacTransform } from "./onemac"; +import { OneMacTransform, RaiIssueTransform } from "./onemac"; import { Action } from "./actions"; export type OsHit = { @@ -32,7 +32,9 @@ export type OsResponse = { aggregations?: OsAggResult; }; -export type OsMainSourceItem = OneMacTransform & SeaToolTransform; +export type OsMainSourceItem = OneMacTransform & + SeaToolTransform & + RaiIssueTransform; export type OsMainSearchResponse = OsResponse; export type SearchData = OsHits; export type ItemResult = OsHit & { diff --git a/src/packages/shared-types/seatool.ts b/src/packages/shared-types/seatool.ts index 22f4bb78cb..82776b3a58 100644 --- a/src/packages/shared-types/seatool.ts +++ b/src/packages/shared-types/seatool.ts @@ -80,16 +80,6 @@ export const seatoolSchema = z.object({ }) ) .nullable(), - STATES: z - .array( - z.object({ - STATE_CODE: z.string(), - STATE_NAME: z.string(), - REGION_ID: z.string(), - PRIORITY_FLAG: z.boolean(), - }) - ) - .nonempty(), PLAN_TYPES: z .array( z.object({ @@ -105,11 +95,14 @@ export const seatoolSchema = z.object({ CHANGED_DATE: z.number().nullable(), APPROVED_EFFECTIVE_DATE: z.number().nullable(), PROPOSED_DATE: z.number().nullable(), + SPW_STATUS_ID: z.number().nullable(), + STATE_CODE: z.string().nullish(), }), SPW_STATUS: z .array( z.object({ - SPW_STATUS_DESC: z.string().nullish(), + SPW_STATUS_DESC: z.string().nullable(), + SPW_STATUS_ID: z.number().nullable(), }) ) .nullable(), @@ -118,6 +111,7 @@ export const seatoolSchema = z.object({ z.object({ RAI_RECEIVED_DATE: z.number().nullable(), RAI_REQUESTED_DATE: z.number().nullable(), + RAI_WITHDRAWN_DATE: z.number().nullable(), }) ) .nullable(), @@ -143,9 +137,32 @@ export const transformSeatoolData = (id: string) => { return seatoolSchema.transform((data) => { const { leadAnalystName, leadAnalystOfficerId } = getLeadAnalyst(data); const { raiReceivedDate, raiRequestedDate } = getRaiDate(data); - const { stateStatus, cmsStatus } = getStatus( - data.SPW_STATUS?.at(-1)?.SPW_STATUS_DESC - ); + const seatoolStatus = + data.SPW_STATUS?.find( + (item) => item.SPW_STATUS_ID === data.STATE_PLAN.SPW_STATUS_ID + )?.SPW_STATUS_DESC || "Unknown"; + const { stateStatus, cmsStatus } = getStatus(seatoolStatus); + const rais: Record< + number, + { + requestedDate: number; + receivedDate: number | null; + withdrawnDate: number | null; + } + > = {}; + if (data.RAI) { + data.RAI.forEach((rai) => { + // Should never be null, but if it is there's nothing we can do with it. + if (rai.RAI_REQUESTED_DATE === null) { + return; + } + rais[rai.RAI_REQUESTED_DATE] = { + requestedDate: rai.RAI_REQUESTED_DATE, + receivedDate: rai.RAI_RECEIVED_DATE, + withdrawnDate: rai.RAI_WITHDRAWN_DATE, + }; + }); + } return { id, actionType: data.ACTIONTYPES?.[0].ACTION_NAME, @@ -162,9 +179,11 @@ export const transformSeatoolData = (id: string) => { proposedDate: getDateStringOrNullFromEpoc(data.STATE_PLAN.PROPOSED_DATE), raiReceivedDate, raiRequestedDate, - state: data.STATES?.[0].STATE_CODE, + rais, + state: data.STATE_PLAN.STATE_CODE, stateStatus: stateStatus || SEATOOL_STATUS.UNKNOWN, cmsStatus: cmsStatus || SEATOOL_STATUS.UNKNOWN, + seatoolStatus, submissionDate: getDateStringOrNullFromEpoc( data.STATE_PLAN.SUBMISSION_DATE ), @@ -176,7 +195,7 @@ export type SeaToolTransform = z.infer>; export type SeaToolSink = z.infer; export type SeaToolRecordsToDelete = Omit< { - [Property in keyof SeaToolTransform]: undefined; + [Property in keyof SeaToolTransform]: null; }, - "id" + "id" | "rais" > & { id: string }; diff --git a/src/packages/shared-types/user.ts b/src/packages/shared-types/user.ts index 6ecccd104b..ae39bac554 100644 --- a/src/packages/shared-types/user.ts +++ b/src/packages/shared-types/user.ts @@ -23,4 +23,11 @@ export const CMS_ROLES = [ UserRoles.HELPDESK, ]; +export const CMS_WRITE_ROLES = [UserRoles.CMS_REVIEWER]; + +export const CMS_READ_ONLY_ROLES = [ + UserRoles.CMS_READ_ONLY, + UserRoles.HELPDESK, +]; + export const STATE_ROLES = [UserRoles.STATE_SUBMITTER]; diff --git a/src/packages/shared-utils/index.ts b/src/packages/shared-utils/index.ts index e92804002c..9e2cecc21c 100644 --- a/src/packages/shared-utils/index.ts +++ b/src/packages/shared-utils/index.ts @@ -1,2 +1,3 @@ -export * from "./is-cms-user"; +export * from "./user-helper"; export * from "./s3-url-parser"; +export * from "./rai-helper" diff --git a/src/packages/shared-utils/is-cms-user.ts b/src/packages/shared-utils/is-cms-user.ts deleted file mode 100644 index 306a605721..0000000000 --- a/src/packages/shared-utils/is-cms-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CMS_ROLES, CognitoUserAttributes } from "../shared-types"; - -export const isCmsUser = (user: CognitoUserAttributes) => { - const userRoles = user["custom:cms-roles"]; - - for (const cmsRole of CMS_ROLES) { - if (userRoles.includes(cmsRole)) { - return true; - } - } - return false; -}; diff --git a/src/packages/shared-utils/rai-helper.ts b/src/packages/shared-utils/rai-helper.ts new file mode 100644 index 0000000000..9c55a4cf30 --- /dev/null +++ b/src/packages/shared-utils/rai-helper.ts @@ -0,0 +1,23 @@ + +export const getLatestRai = (rais: any) => { + const keys = Object.keys(rais); + if (keys.length === 0) { + return null; + } else { + const maxKey = keys.reduce((max, key) => Math.max(max, Number(key)), -Infinity); + return { + key: maxKey, + value: rais[maxKey], + }; + } +}; + +export const getActiveRai = (rais:any) => { + const latest = getLatestRai(rais); + // If the latest RAI does not have a received or withdrawn date + if(latest && !latest.value.receivedDate && !latest.value.withdrawnDate){ + return latest + } else { + return null + } +} diff --git a/src/packages/shared-utils/user-helper.ts b/src/packages/shared-utils/user-helper.ts new file mode 100644 index 0000000000..b0f3df46be --- /dev/null +++ b/src/packages/shared-utils/user-helper.ts @@ -0,0 +1,46 @@ +import { CMS_ROLES, CMS_WRITE_ROLES, CMS_READ_ONLY_ROLES, CognitoUserAttributes, STATE_ROLES } from "../shared-types"; + +export const isCmsUser = (user: CognitoUserAttributes) => { + const userRoles = user["custom:cms-roles"]; + + for (const cmsRole of CMS_ROLES) { + if (userRoles.includes(cmsRole)) { + return true; + } + } + return false; +}; + +export const isCmsWriteUser = (user: CognitoUserAttributes) => { + const userRoles = user["custom:cms-roles"]; + + for (const role of CMS_WRITE_ROLES) { + if (userRoles.includes(role)) { + return true; + } + } + return false; +}; + +export const isCmsReadonlyUser = (user: CognitoUserAttributes) => { + const userRoles = user["custom:cms-roles"]; + + for (const role of CMS_READ_ONLY_ROLES) { + if (userRoles.includes(role)) { + return true; + } + } + return false; +} + +export const isStateUser = (user: CognitoUserAttributes) => { + const userRoles = user["custom:cms-roles"]; + + for (const role of STATE_ROLES) { + if (userRoles.includes(role)) { + return true; + } + } + return false; + +} diff --git a/src/services/api/handlers/action.ts b/src/services/api/handlers/action.ts index 8c99a38da8..4d8c1978d4 100644 --- a/src/services/api/handlers/action.ts +++ b/src/services/api/handlers/action.ts @@ -8,7 +8,11 @@ import { } from "../libs/auth/user"; import { packageActionsForResult } from "./getPackageActions"; import { Action } from "shared-types"; -import { issueRai, toggleRaiResponseWithdraw } from "./packageActions"; +import { + issueRai, + respondToRai, + toggleRaiResponseWithdraw, +} from "./packageActions"; export const handler = async (event: APIGatewayEvent) => { try { @@ -50,7 +54,10 @@ export const handler = async (event: APIGatewayEvent) => { // Call package action switch (actionType) { case Action.ISSUE_RAI: - await issueRai(body.id, Date.now()); + await issueRai(body); + break; + case Action.RESPOND_TO_RAI: + await respondToRai(body, result._source.rais); break; case Action.ENABLE_RAI_WITHDRAW: await toggleRaiResponseWithdraw(body, true); diff --git a/src/services/api/handlers/getAttachmentUrl.ts b/src/services/api/handlers/getAttachmentUrl.ts index f8f486ee4a..14d6745739 100644 --- a/src/services/api/handlers/getAttachmentUrl.ts +++ b/src/services/api/handlers/getAttachmentUrl.ts @@ -49,10 +49,11 @@ export const handler = async (event: APIGatewayEvent) => { } const allAttachments = [ - ...results.hits.hits[0]._source.attachments, - ...results.hits.hits[0]._source.raiResponses - .map((R) => R.attachments) - .flat(), + ...(results.hits.hits[0]._source.attachments || []), + ...Object.values(results.hits.hits[0]._source.rais).flatMap((entry) => [ + ...(entry.request.attachments || []), + ...(entry.response.attachments || []), + ]), ]; if ( diff --git a/src/services/api/handlers/getPackageActions.ts b/src/services/api/handlers/getPackageActions.ts index 87fb4d1bc0..991dd2f029 100644 --- a/src/services/api/handlers/getPackageActions.ts +++ b/src/services/api/handlers/getPackageActions.ts @@ -1,6 +1,11 @@ import { APIGatewayEvent } from "aws-lambda"; import { Action, CognitoUserAttributes, ItemResult } from "shared-types"; -import { isCmsUser } from "shared-utils"; +import { + isCmsUser, + getActiveRai, + isCmsWriteUser, + isStateUser, +} from "shared-utils"; import { getPackage } from "../libs/package/getPackage"; import { getAuthDetails, @@ -8,6 +13,7 @@ import { lookupUserAttributes, } from "../libs/auth/user"; import { response } from "../libs/handler"; +import { SEATOOL_STATUS } from "shared-types/statusHelper"; type GetPackageActionsBody = { id: string; @@ -20,15 +26,41 @@ export const packageActionsForResult = ( result: ItemResult ): Action[] => { const actions = []; - if (isCmsUser(user)) { - if (!result._source.raiWithdrawEnabled) { - // result._source.raiReceivedDate && - actions.push(Action.ENABLE_RAI_WITHDRAW); + const activeRai = getActiveRai(result._source.rais); + if (isCmsWriteUser(user)) { + switch (result._source.seatoolStatus) { + case SEATOOL_STATUS.PENDING: + case SEATOOL_STATUS.PENDING_OFF_THE_CLOCK: + case SEATOOL_STATUS.PENDING_APPROVAL: + case SEATOOL_STATUS.PENDING_CONCURRENCE: + if (!activeRai) { + // If there is not an active RAI + actions.push(Action.ISSUE_RAI); + } + break; + } + if ( + result._source.rais !== null && + Object.keys(result._source.rais).length > 0 && + !activeRai + ) { + // There's an RAI and its been responded to + if (!result._source.raiWithdrawEnabled) { + actions.push(Action.ENABLE_RAI_WITHDRAW); + } + if (result._source.raiWithdrawEnabled) { + actions.push(Action.DISABLE_RAI_WITHDRAW); + } } - if (result._source.raiWithdrawEnabled) { - actions.push(Action.DISABLE_RAI_WITHDRAW); + } else if (isStateUser(user)) { + switch (result._source.seatoolStatus) { + case SEATOOL_STATUS.PENDING_RAI: + if (activeRai) { + // If there is an active RAI + actions.push(Action.RESPOND_TO_RAI); + } + break; } - actions.push(Action.ISSUE_RAI); } return actions; }; diff --git a/src/services/api/handlers/packageActions.ts b/src/services/api/handlers/packageActions.ts index 53299788bc..80bb9da6c7 100644 --- a/src/services/api/handlers/packageActions.ts +++ b/src/services/api/handlers/packageActions.ts @@ -12,36 +12,132 @@ const config = { database: "SEA", }; -import { Action, OneMacSink, transformOnemac } from "shared-types"; +import { Action, raiSchema, RaiSchema } from "shared-types"; import { produceMessage } from "../libs/kafka"; import { response } from "../libs/handler"; +import { SEATOOL_STATUS } from "shared-types/statusHelper"; +import { getActiveRai, getLatestRai } from "shared-utils"; const TOPIC_NAME = process.env.topicName; -export async function issueRai(id: string, timestamp: number) { +export async function issueRai(body: RaiSchema) { console.log("CMS issuing a new RAI"); const pool = await sql.connect(config); - const query = ` - Insert into SEA.dbo.RAI (ID_Number, RAI_Requested_Date) - values ('${id}' - ,dateadd(s, convert(int, left(${timestamp}, 10)), cast('19700101' as datetime))) - `; - // Prepare the request - const request = pool.request(); - request.input("ID_Number", sql.VarChar, id); - request.input("RAI_Requested_Date", sql.DateTime, new Date(timestamp)); - - const result = await sql.query(query); - console.log(result); - await pool.close(); + const transaction = new sql.Transaction(pool); + try { + await transaction.begin(); + // Issue RAI + const query1 = ` + Insert into SEA.dbo.RAI (ID_Number, RAI_Requested_Date) + values ('${body.id}' + ,dateadd(s, convert(int, left(${body.requestedDate}, 10)), cast('19700101' as datetime))) + `; + const result1 = await transaction.request().query(query1); + console.log(result1); + + // Update Status + const query2 = ` + UPDATE SEA.dbo.State_Plan + SET SPW_Status_ID = (Select SPW_Status_ID from SEA.dbo.SPW_Status where SPW_Status_DESC = '${SEATOOL_STATUS.PENDING_RAI}') + WHERE ID_Number = '${body.id}' + `; + const result2 = await transaction.request().query(query2); + console.log(result2); + + // write to kafka here + const result = raiSchema.safeParse(body); + if (result.success === false) { + console.log( + "RAI Validation Error. The following record failed to parse: ", + JSON.stringify(body), + "Because of the following Reason(s):", + result.error.message + ); + } else { + await produceMessage( + TOPIC_NAME, + body.id, + JSON.stringify({ ...result.data, actionType: Action.ISSUE_RAI }) + ); + } + + // Commit transaction + await transaction.commit(); + } catch (err) { + // Rollback and log + await transaction.rollback(); + console.error("Error executing one or both queries:", err); + throw err; + } finally { + // Close pool + await pool.close(); + } } export async function withdrawRai(id, timestamp) { console.log("CMS withdrawing an RAI"); } -export async function respondToRai(id, timestamp) { - console.log("State respnding to RAI"); +export async function respondToRai(body: RaiSchema, rais: any) { + console.log("State responding to RAI"); + const activeKey = getActiveRai(rais).key; + console.log("LATEST RAI KEY: " + activeKey); + const pool = await sql.connect(config); + const transaction = new sql.Transaction(pool); + console.log(body); + try { + await transaction.begin(); + // Issue RAI + const query1 = ` + UPDATE SEA.dbo.RAI + SET RAI_RECEIVED_DATE = DATEADD(s, CONVERT(int, LEFT('${body.responseDate}', 10)), CAST('19700101' AS DATETIME)) + WHERE ID_Number = '${body.id}' AND RAI_REQUESTED_DATE = DATEADD(s, CONVERT(int, LEFT('${activeKey}', 10)), CAST('19700101' AS DATETIME)) + `; + const result1 = await transaction.request().query(query1); + console.log(result1); + + // Update Status + const query2 = ` + UPDATE SEA.dbo.State_Plan + SET SPW_Status_ID = (Select SPW_Status_ID from SEA.dbo.SPW_Status where SPW_Status_DESC = '${SEATOOL_STATUS.PENDING}') + WHERE ID_Number = '${body.id}' + `; + const result2 = await transaction.request().query(query2); + console.log(result2); + + // // write to kafka here + const result = raiSchema.safeParse({ ...body, requestedDate: activeKey }); + if (result.success === false) { + console.log( + "RAI Validation Error. The following record failed to parse: ", + JSON.stringify(body), + "Because of the following Reason(s):", + result.error.message + ); + } else { + console.log(JSON.stringify(result, null, 2)); + await produceMessage( + TOPIC_NAME, + body.id, + JSON.stringify({ + ...result.data, + actionType: Action.RESPOND_TO_RAI, + }) + ); + } + + // Commit transaction + await transaction.commit(); + } catch (err) { + // Rollback and log + await transaction.rollback(); + console.error("Error executing one or both queries:", err); + throw err; + } finally { + // Close pool + await pool.close(); + } + console.log("heyo"); } export async function withdrawPackage(id, timestamp) { diff --git a/src/services/data/handlers/index.ts b/src/services/data/handlers/index.ts new file mode 100644 index 0000000000..27f074db24 --- /dev/null +++ b/src/services/data/handlers/index.ts @@ -0,0 +1,50 @@ +import { Handler } from "aws-lambda"; +import { send, SUCCESS, FAILED } from "cfn-response-async"; +type ResponseStatus = typeof SUCCESS | typeof FAILED; +import * as os from "./../../../libs/opensearch-lib"; + +export const customResourceWrapper: Handler = async (event, context) => { + console.log("request:", JSON.stringify(event, undefined, 2)); + const responseData = {}; + let responseStatus: ResponseStatus = SUCCESS; + try { + if (event.RequestType == "Create" || event.RequestType == "Update") { + await manageIndex(); + } + } catch (error) { + console.log(error); + responseStatus = FAILED; + } finally { + console.log("finally"); + await send(event, context, responseStatus, responseData); + } +}; + +export const handler: Handler = async (event) => { + await manageIndex(); +}; + +async function manageIndex() { + try { + const createIndexReponse = await os.createIndexIfNotExists( + process.env.osDomain, + "main" + ); + console.log(createIndexReponse); + + const updateFieldMappingResponse = await os.updateFieldMapping( + process.env.osDomain, + "main", + { + rais: { + type: "object", + enabled: false, + }, + } + ); + console.log(updateFieldMappingResponse); + } catch (error) { + console.log(error); + throw "ERROR: Error occured during index management."; + } +} diff --git a/src/services/data/handlers/sink.ts b/src/services/data/handlers/sink.ts index 35cdf16076..17013b12ef 100644 --- a/src/services/data/handlers/sink.ts +++ b/src/services/data/handlers/sink.ts @@ -10,8 +10,12 @@ import { OneMacRecordsToDelete, OneMacTransform, transformOnemac, + RaiIssueTransform, + transformRaiIssue, + RaiResponseTransform, + transformRaiResponse, } from "shared-types/onemac"; -import { Action, WithdrawRecord, withdrawRecordSchema } from "shared-types"; +import { Action, withdrawRecordSchema, WithdrawRecord } from "shared-types"; if (!process.env.osDomain) { throw "ERROR: process.env.osDomain is required,"; @@ -54,22 +58,23 @@ export const seatool: Handler = async (event) => { const id: string = JSON.parse(decode(key)); const seaTombstone: SeaToolRecordsToDelete = { id, - actionType: undefined, - actionTypeId: undefined, - approvedEffectiveDate: undefined, - authority: undefined, - changedDate: undefined, - leadAnalystName: undefined, - leadAnalystOfficerId: undefined, - planType: undefined, - planTypeId: undefined, - proposedDate: undefined, - raiReceivedDate: undefined, - raiRequestedDate: undefined, - state: undefined, - cmsStatus: undefined, - stateStatus: undefined, - submissionDate: undefined, + actionType: null, + actionTypeId: null, + approvedEffectiveDate: null, + authority: null, + changedDate: null, + leadAnalystName: null, + leadAnalystOfficerId: null, + planType: null, + planTypeId: null, + proposedDate: null, + raiReceivedDate: null, + raiRequestedDate: null, + state: null, + cmsStatus: null, + stateStatus: null, + seatoolStatus: null, + submissionDate: null, }; docObject[id] = seaTombstone; @@ -97,6 +102,8 @@ export const onemac: Handler = async (event) => { | OneMacTransform | OneMacRecordsToDelete | (WithdrawRecord & { id: string }) + | RaiIssueTransform + | RaiResponseTransform )[] = []; for (const recordKey of Object.keys(event.records)) { @@ -110,9 +117,8 @@ export const onemac: Handler = async (event) => { const id: string = decode(key); const record = { id, ...JSON.parse(decode(value)) }; const isActionType = "actionType" in record; - if (isActionType) { - switch (record.actionType as Action) { + switch (record.actionType) { case Action.ENABLE_RAI_WITHDRAW: case Action.DISABLE_RAI_WITHDRAW: { const result = withdrawRecordSchema.safeParse(record); @@ -128,11 +134,32 @@ export const onemac: Handler = async (event) => { `ERROR: Invalid Payload for this action type (${record.actionType})` ); } - break; } - case Action.ISSUE_RAI: - return; + case Action.ISSUE_RAI: { + const result = transformRaiIssue(id).safeParse(record); + if (result.success) { + oneMacRecords.push(result.data); + } else { + console.log( + `ERROR: Invalid Payload for this action type (${record.actionType})` + ); + } + break; + } + case Action.RESPOND_TO_RAI: { + console.log("RESPONDING"); + const result = transformRaiResponse(id).safeParse(record); + if (result.success) { + console.log(result.data); + oneMacRecords.push(result.data); + } else { + console.log( + `ERROR: Invalid Payload for this action type (${record.actionType})` + ); + } + break; + } } } else if ( record && // testing if we have a record @@ -157,13 +184,12 @@ export const onemac: Handler = async (event) => { const id: string = decode(key); const oneMacTombstone: OneMacRecordsToDelete = { id, - additionalInformation: undefined, - raiWithdrawEnabled: undefined, - attachments: undefined, - submitterEmail: undefined, - submitterName: undefined, - origin: undefined, - raiResponses: undefined, + additionalInformation: null, + raiWithdrawEnabled: null, + attachments: null, + submitterEmail: null, + submitterName: null, + origin: null, }; oneMacRecords.push(oneMacTombstone); diff --git a/src/services/data/serverless.yml b/src/services/data/serverless.yml index c030bec139..4c7aac441e 100644 --- a/src/services/data/serverless.yml +++ b/src/services/data/serverless.yml @@ -127,6 +127,12 @@ stepFunctions: DeleteIndex: Type: Task Resource: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${sls:stage}-deleteIndex" + Parameters: + Context.$: $$ + Next: SetupIndex + SetupIndex: + Type: Task + Resource: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${sls:stage}-setupIndex" Parameters: Context.$: $$ Next: EnableTriggers @@ -162,7 +168,7 @@ functions: region: ${self:provider.region} osDomain: !Sub https://${OpenSearch.DomainEndpoint} maximumRetryAttempts: 0 - timeout: 10 + timeout: 60 memorySize: 1024 vpc: securityGroupIds: @@ -175,7 +181,7 @@ functions: region: ${self:provider.region} osDomain: !Sub https://${OpenSearch.DomainEndpoint} maximumRetryAttempts: 0 - timeout: 10 + timeout: 60 memorySize: 1024 vpc: securityGroupIds: @@ -269,6 +275,32 @@ functions: userPoolId: !Ref CognitoUserPool bootstrapUsersPassword: ${self:custom.bootstrapUsersPassword} region: ${self:provider.region} + setupIndexCustomResource: + handler: handlers/index.customResourceWrapper + environment: + region: ${self:provider.region} + osDomain: !Sub https://${OpenSearch.DomainEndpoint} + maximumRetryAttempts: 0 + timeout: 60 + memorySize: 1024 + vpc: + securityGroupIds: + - Ref: SecurityGroup + subnetIds: >- + ${self:custom.vpc.privateSubnets} + setupIndex: + handler: handlers/index.handler + environment: + region: ${self:provider.region} + osDomain: !Sub https://${OpenSearch.DomainEndpoint} + maximumRetryAttempts: 0 + timeout: 60 + memorySize: 1024 + vpc: + securityGroupIds: + - Ref: SecurityGroup + subnetIds: >- + ${self:custom.vpc.privateSubnets} resources: Conditions: @@ -306,6 +338,7 @@ resources: StartingPosition: TRIM_HORIZON Topics: - aws.ksqldb.seatool.agg.State_Plan + DependsOn: SetupIndex CloudWatchAlarmForSinkSeatoolTriggerErrors: Type: AWS::CloudWatch::Alarm Properties: @@ -373,6 +406,7 @@ resources: StartingPosition: TRIM_HORIZON Topics: - ${param:topicNamespace}aws.onemac.migration.cdc + DependsOn: SetupIndex CloudWatchAlarmForSinkOnemacTriggerErrors: Type: AWS::CloudWatch::Alarm Properties: @@ -674,6 +708,11 @@ resources: BrokerString: ${self:custom.brokerString} TopicPatternsToDelete: - ${param:topicNamespace}aws.onemac.migration.cdc + SetupIndex: + Type: Custom::SetupIndex + Properties: + ServiceToken: !GetAtt SetupIndexCustomResourceLambdaFunction.Arn + DependsOn: MapRole # This lambda needs the roles mapped before setting up the index Outputs: OpenSearchDomainArn: Value: !GetAtt OpenSearch.Arn diff --git a/src/services/ui/src/components/AttachmentsList/index.tsx b/src/services/ui/src/components/AttachmentsList/index.tsx index 64ae02d129..79c4ab229d 100644 --- a/src/services/ui/src/components/AttachmentsList/index.tsx +++ b/src/services/ui/src/components/AttachmentsList/index.tsx @@ -71,7 +71,6 @@ export const Attachmentslist = (data: AttachmentList) => { attachment.key, attachment.filename ); - console.log(url); window.open(url); }} > diff --git a/src/services/ui/src/components/Opensearch/Filtering/index.tsx b/src/services/ui/src/components/Opensearch/Filtering/index.tsx index 223a77fa32..5e8ed8eebe 100644 --- a/src/services/ui/src/components/Opensearch/Filtering/index.tsx +++ b/src/services/ui/src/components/Opensearch/Filtering/index.tsx @@ -47,7 +47,7 @@ export const OsFiltering: FC<{ }, { name: "State", - transform: (data) => data.state, + transform: (data) => data.state ?? BLANK_VALUE, }, { name: "Type", diff --git a/src/services/ui/src/components/RaiList/index.tsx b/src/services/ui/src/components/RaiList/index.tsx new file mode 100644 index 0000000000..56fca0c523 --- /dev/null +++ b/src/services/ui/src/components/RaiList/index.tsx @@ -0,0 +1,177 @@ +import { OsMainSourceItem } from "shared-types"; +import { DetailsSection } from "../DetailsSection"; +import { format } from "date-fns"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Attachmentslist, +} from "@/components"; +import { BLANK_VALUE } from "@/consts"; + +export const RaiList = (data: OsMainSourceItem) => { + if (!data.rais) return null; + return ( + + {Object.keys(data.rais).length > 0 ? ( + (() => { + const sortedKeys = Object.keys(data.rais) + .map(Number) + .sort((a, b) => b - a); + return ( +
+ {sortedKeys.map((key, i) => ( + + + {`RAI #${ + Object.keys(data.rais).length - i + } - ${getLatestStatus(data.rais[key])}`} + +
+

+ CMS Request Info +

+ {data.rais[key].requestedDate ? ( // if has data + <> +

+ Submitted Time: +

+

+ {format( + new Date(data.rais[key].requestedDate), + "EEE, MMM d yyyy, h:mm:ss a" + )} +

+

+ Submitted By: +

+

+ {data.rais[key].request?.submitterName || + BLANK_VALUE} +

+

+ Attachments +

+ {data.rais[key].request?.attachments ? ( +
+ +
+ ) : ( +

{BLANK_VALUE}

+ )} +

+ Additional Information +

+

+ {data.rais[key].request?.additionalInformation ?? + BLANK_VALUE} +

+ + ) : ( +

No Request Recorded

+ )} +
+
+

+ State Response Info +

+ {data.rais[key].receivedDate ? ( // if has data + <> +

+ Submitted Time: +

+

+ {format( + new Date(data.rais[key].receivedDate || ""), // idky its complaining, because + "EEE, MMM d yyyy, h:mm:ss a" + )} +

+

+ Submitted By: +

+

+ {data.rais[key].response?.submitterName || + BLANK_VALUE} +

+

+ Attachments +

+ {data.rais[key].response?.attachments ? ( +
+ +
+ ) : ( +

{BLANK_VALUE}

+ )} +

+ Additional Information +

+

+ {data.rais[key].response?.additionalInformation ?? + BLANK_VALUE} +

+ + ) : ( +

No Response Recorded

+ )} +
+
+
+
+ ))} +
+ ); + })() + ) : ( +

{BLANK_VALUE}

+ )} +
+ ); +}; + +function getLatestStatus(rai: any) { + const { receivedDate, requestedDate, withdrawnDate } = rai; + + // Filter out null and undefined values and handle the case when all dates are null or undefined + const filteredNumbers: number[] = [ + requestedDate, + receivedDate, + withdrawnDate, + ].filter((num) => num !== null && num !== undefined) as number[]; + + if (filteredNumbers.length === 0) { + return "No date recorded"; + } + + const latestDate = Math.max(...filteredNumbers); + let retString = ""; + + if (latestDate === receivedDate) { + retString += "Responded:"; + } else if (latestDate === requestedDate) { + retString += "Requested:"; + } else if (latestDate === withdrawnDate) { + retString += "Withdrawn:"; + } + + // Check if latestDate is a valid number before formatting + if (!isNaN(latestDate) && isFinite(latestDate)) { + return `${retString} ${format( + new Date(latestDate), + "EEE, MMM d yyyy, h:mm a" + )}`; + } else { + return "Invalid date"; + } +} diff --git a/src/services/ui/src/components/RaiResponses/index.tsx b/src/services/ui/src/components/RaiResponses/index.tsx deleted file mode 100644 index d58fae0615..0000000000 --- a/src/services/ui/src/components/RaiResponses/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { OsMainSourceItem } from "shared-types"; -import { DetailsSection } from "../DetailsSection"; -import { format } from "date-fns"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, - Attachmentslist, -} from "@/components"; - -export const RaiResponses = (data: OsMainSourceItem) => { - if (!data.raiResponses || data.raiResponses.length === 0) return null; - return ( - data.raiResponses && ( - - {data.raiResponses.map((R, i) => { - return ( - - - {`Submitted on ${format( - new Date(R.submissionTimestamp), - "EEE, MMM d yyyy, h:mm:ss a" - )}`} - -
-

- RAI Response Documentation -

-

- Documents available on this page may not reflect the - actual documents that were approved by CMS. Please refer - to your CMS Point of Contact for the approved documents. -

- -

- Additional Information -

-

{R.additionalInformation}

-
-
-
-
- ); - })} -
- ) - ); -}; diff --git a/src/services/ui/src/components/index.tsx b/src/services/ui/src/components/index.tsx index 7d93609bf9..e1e79751d3 100644 --- a/src/services/ui/src/components/index.tsx +++ b/src/services/ui/src/components/index.tsx @@ -12,7 +12,7 @@ export * from "./HowItWorks"; export * from "./Layout"; export * from "./LoadingSpinner"; export * from "./PackageDetails"; -export * from "./RaiResponses"; +export * from "./RaiList"; export * from "./SearchForm"; export * from "./SubmissionInfo"; export * from "./Modal"; diff --git a/src/services/ui/src/pages/actions/IssueRai.tsx b/src/services/ui/src/pages/actions/IssueRai.tsx index badc89f60c..bef9d62b65 100644 --- a/src/services/ui/src/pages/actions/IssueRai.tsx +++ b/src/services/ui/src/pages/actions/IssueRai.tsx @@ -5,81 +5,251 @@ import { useState } from "react"; import { type SubmitHandler, useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useGetUser } from "@/api/useGetUser"; -// import { BREAD_CRUMB_CONFIG_PACKAGE_DETAILS } from "@/components/BreadCrumb/bread-crumb-config"; +import { DETAILS_AND_ACTIONS_CRUMBS } from "@/pages/actions/actions-breadcrumbs"; import { SimplePageContainer, Alert, LoadingSpinner, - Modal, BreadCrumbs, } from "@/components"; +import { Modal } from "@/components/Modal/Modal"; import { FAQ_TARGET, ROUTES } from "@/routes"; import { Link, useNavigate } from "react-router-dom"; +import { Action, RaiIssueTransform } from "shared-types"; +import { useGetUser } from "@/api/useGetUser"; +import { useGetItem } from "@/api"; const formSchema = z.object({ - additionalInformation: z.string().max(4000).optional(), + additionalInformation: z.string().max(4000), + attachments: z.object({ + formalRaiLetter: z + .array(z.instanceof(File)) + .refine((value) => value.length > 0, { + message: "Required", + }), + other: z.array(z.instanceof(File)).optional(), + }), }); export type IssueRaiFormSchema = z.infer; +type UploadKeys = keyof IssueRaiFormSchema["attachments"]; +export type PreSignedURL = { + url: string; + key: string; + bucket: string; +}; + +const attachmentList = [ + { + name: "formalRaiLetter", + label: "Formal RAI Letter", + required: true, + }, + { + name: "other", + label: "Other", + required: false, + }, +] as const; + +const FormDescriptionText = () => { + return ( +

+ Issuance of a Formal RAI in OneMAC will create a Formal RAI email sent to + the State. This will also create a section in the package details summary + for you and the State to have record. Please attach the Formal RAI Letter + along with any additional information or comments in the provided text + box. Once you submit this form, a confirmation email is sent to you and to + the State.{" "} + + If you leave this page, you will lose your progress on this form. + +

+ ); +}; export const IssueRai = () => { - const { id, type } = useParams<{ + const { id } = useParams<{ id: string; - type: string; }>(); + const { data: item } = useGetItem(id!); + const [successModalIsOpen, setSuccessModalIsOpen] = useState(false); + const [errorModalIsOpen, setErrorModalIsOpen] = useState(false); + const [cancelModalIsOpen, setCancelModalIsOpen] = useState(false); + const navigate = useNavigate(); const form = useForm({ resolver: zodResolver(formSchema), }); const { data: user } = useGetUser(); - const [successModalIsOpen, setSuccessModalIsOpen] = useState(false); - const [errorModalIsOpen, setErrorModalIsOpen] = useState(false); - const [cancelModalIsOpen, setCancelModalIsOpen] = useState(false); - const handleSubmit: SubmitHandler = async (data) => { - console.log(data); + // Set the timestamp that will uniquely identify this RAI + const timestamp = Math.floor(new Date().getTime() / 1000) * 1000; // Truncating to match seatool + + const uploadKeys = Object.keys(data.attachments) as UploadKeys[]; + const uploadedFiles: any[] = []; + const fileMetaData: NonNullable< + RaiIssueTransform["rais"][number]["request"]["attachments"] + > = []; + + const presignedUrls: Promise[] = uploadKeys + .filter((key) => data.attachments[key] !== undefined) + .map(() => + API.post("os", "/getUploadUrl", { + body: {}, + }) + ); + const loadPresignedUrls = await Promise.all(presignedUrls); + + uploadKeys + .filter((key) => data.attachments[key] !== undefined) + .forEach((uploadKey, index) => { + const attachmenListObject = attachmentList?.find( + (item) => item.name === uploadKey + ); + const title = attachmenListObject ? attachmenListObject.label : "Other"; + const fileGroup = data.attachments[uploadKey] as File[]; + + // upload all files in this group and track there name + for (const file of fileGroup) { + uploadedFiles.push( + fetch(loadPresignedUrls[index].url, { + body: file, + method: "PUT", + }) + ); + + fileMetaData.push({ + key: loadPresignedUrls[index].key, + filename: file.name, + title: title, + bucket: loadPresignedUrls[index].bucket, + uploadDate: Date.now(), + }); + } + }); + + await Promise.all(uploadedFiles); + const dataToSubmit = { - id, + id: id!, additionalInformation: data?.additionalInformation ?? null, + attachments: fileMetaData, + requestedDate: timestamp, + submitterEmail: user?.user?.email ?? "N/A", + submitterName: + `${user?.user?.given_name} ${user?.user?.family_name}` ?? "N/A", }; - let actionResponse; try { - console.log(dataToSubmit); - actionResponse = await API.post("os", "/action/issue-rai", { + await API.post("os", `/action/${Action.ISSUE_RAI}`, { body: dataToSubmit, }); - console.log(actionResponse); - // setSuccessModalIsOpen(true); - console.log("END OF TRY"); + setSuccessModalIsOpen(true); } catch (err) { console.log(err); setErrorModalIsOpen(true); - console.log("CATCH"); } }; return ( - {/* */} +
-

Issue RAI

+

Formal RAI Details

Indicates a required field

-

- Once you submit this form, a confirmation email is sent to you and - to the original submitter.{" "} - - If you leave this page, you will lose your progress on this - form. - + +

+ {/*-------------------------------------------------------- */} +
+

+ Package Details +

+
+ + + {id} + +
+
+ + + {item?._source.planType} + +
+
+ {/*-------------------------------------------------------- */} +
+

Attachments

+

+ Maximum file size of 80 MB per attachment.{" "} + You can add multiple files per attachment type.{" "} + Read the description for each of the attachment types on the{" "} + { + + FAQ Page + + } + . +

+
+

+ We accept the following file formats:{" "} + .docx, .jpg, .png, .pdf, .xlsx,{" "} + and a few others. See the full list on the{" "} + { + + FAQ Page + + } + . +

+
+

+ + At least one attachment is required.

+ {attachmentList.map(({ name, label, required }) => ( + ( + + + {label} + {required ? : ""} + + + + + )} + /> + ))} {/*-------------------------------------------------------- */} { render={({ field }) => (

- Additional Information + Additional Information*

- Add anything else you would like to share with the state - regarding this RAI. + Add anything else you would like to share with the state. 4,000 characters allowed @@ -99,22 +268,17 @@ export const IssueRai = () => { )} /> {/*-------------------------------------------------------- */} -
- - Once you submit this form, a confirmation email is sent to you and - to the original submitter. - -
+ {Object.keys(form.formState.errors).length !== 0 ? ( Missing or malformed information. Please see errors above. ) : null} - {form.formState.isSubmitting ? ( + {form.formState.isSubmitting && (
- ) : null} + )}
{ Cancel } + open={successModalIsOpen} + onAccept={() => { + setSuccessModalIsOpen(false); + navigate(`/details?id=${id}`); + }} + onCancel={() => setSuccessModalIsOpen(false)} + title="The Formal RAI has been issued." + body={ +

+ The Formal RAI has been issued successfully. You and the State + will receive an email confirmation. +

+ } + cancelButtonVisible={false} + acceptButtonText="Exit to Package Details" /> + open={errorModalIsOpen} + onAccept={() => { + setErrorModalIsOpen(false); + navigate(`/details?id=${id}`); + }} + onCancel={() => setErrorModalIsOpen(false)} + title="Submission Error" + body={ +

+ An error occurred during issue. +
+ You may close this window and try again, however, this likely + requires support. +
+
+ Please contact the{" "} + + helpdesk + {" "} + . You may include the following in your support request:{" "} +
+
+

    +
  • SPA ID: {id}
  • +
  • Timestamp: {Date.now()}
  • +
+

} + cancelButtonVisible={true} + cancelButtonText="Return to Form" + acceptButtonText="Exit to Package Details" /> + open={cancelModalIsOpen} + onAccept={() => { + setCancelModalIsOpen(false); + navigate(`/details?id=${id}`); + }} + onCancel={() => setCancelModalIsOpen(false)} + cancelButtonText="Return to Form" + acceptButtonText="Yes" + title="Are you sure you want to cancel?" + body={ +

+ If you leave this page you will lose your progress on this + form +

} />
@@ -161,100 +371,3 @@ export const IssueRai = () => { ); }; - -type SuccessModalProps = { - id: string; -}; -const SuccessModalContent = ({ id }: SuccessModalProps) => { - const navigate = useNavigate(); - return ( -
-
-
Submission Success!
-

- RAI for {id} was successfully issued. -
- Please be aware that it may take up to a minute for this action to be - reflected in the dashboard. -

-
- navigate(ROUTES.DASHBOARD)} - > - Go to Dashboard - -
- ); -}; - -type ErrorModalProps = { id: string; setModalIsOpen: (open: boolean) => void }; -const ErrorModalContent = ({ id, setModalIsOpen }: ErrorModalProps) => { - return ( -
-
-
Submission Error:
-

- An error occurred during issue. -
- You may close this window and try again, however, this likely requires - support. -
-
- Please contact the{" "} - - helpdesk - {" "} - . You may include the following in your support request:
-
-

    -
  • SPA ID: {id}
  • -
  • Timestamp: {Date.now()}
  • -
-

-
- setModalIsOpen(false)} - > - Close - -
- ); -}; - -type CancelModalProps = { setCancelModalIsOpen: (open: boolean) => void }; -const CancelModalContent = ({ setCancelModalIsOpen }: CancelModalProps) => { - const navigate = useNavigate(); - return ( -
-
-
Are you sure you want to cancel?
-

If you leave this page, you will lose your progress on this form.

-
-
- navigate(ROUTES.DASHBOARD)} - > - Yes - -
- setCancelModalIsOpen(false)} - > - No, Return to Form - -
-
-
- ); -}; diff --git a/src/services/ui/src/pages/actions/RespondToRai.tsx b/src/services/ui/src/pages/actions/RespondToRai.tsx new file mode 100644 index 0000000000..ec200ddea3 --- /dev/null +++ b/src/services/ui/src/pages/actions/RespondToRai.tsx @@ -0,0 +1,372 @@ +import { useParams } from "react-router-dom"; +import * as I from "@/components/Inputs"; +import { API } from "aws-amplify"; +import { useState } from "react"; +import { type SubmitHandler, useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { DETAILS_AND_ACTIONS_CRUMBS } from "@/pages/actions/actions-breadcrumbs"; +import { + SimplePageContainer, + Alert, + LoadingSpinner, + BreadCrumbs, +} from "@/components"; +import { Modal } from "@/components/Modal/Modal"; +import { FAQ_TARGET, ROUTES } from "@/routes"; +import { Link, useNavigate } from "react-router-dom"; +import { Action, RaiResponseTransform } from "shared-types"; +import { useGetUser } from "@/api/useGetUser"; +import { useGetItem } from "@/api"; + +const formSchema = z.object({ + additionalInformation: z.string().max(4000).optional(), + attachments: z.object({ + raiResponseLetter: z + .array(z.instanceof(File)) + .refine((value) => value.length > 0, { + message: "Required", + }), + other: z.array(z.instanceof(File)).optional(), + }), +}); +export type RespondToRaiFormSchema = z.infer; +type UploadKeys = keyof RespondToRaiFormSchema["attachments"]; +export type PreSignedURL = { + url: string; + key: string; + bucket: string; +}; + +const attachmentList = [ + { + name: "raiResponseLetter", + label: "RAI Response Letter", + required: true, + }, + { + name: "other", + label: "Other", + required: false, + }, +] as const; + +const FormDescriptionText = () => { + return ( +

+ Once you submit this form, a confirmation email is sent to you and to CMS. + CMS will use this content to review your package, and you will not be able + to edit this form. If CMS needs any additional information, they will + follow up by email.{" "} + + If you leave this page, you will lose your progress on this form. + +

+ ); +}; + +export const RespondToRai = () => { + const { id } = useParams<{ + id: string; + }>(); + const { data: item } = useGetItem(id!); + const [successModalIsOpen, setSuccessModalIsOpen] = useState(false); + const [errorModalIsOpen, setErrorModalIsOpen] = useState(false); + const [cancelModalIsOpen, setCancelModalIsOpen] = useState(false); + const navigate = useNavigate(); + const form = useForm({ + resolver: zodResolver(formSchema), + }); + const { data: user } = useGetUser(); + const handleSubmit: SubmitHandler = async (data) => { + const timestamp = Math.floor(new Date().getTime() / 1000) * 1000; // Truncating to match seatool + + const uploadKeys = Object.keys(data.attachments) as UploadKeys[]; + const uploadedFiles: any[] = []; + const fileMetaData: NonNullable< + RaiResponseTransform["rais"][number]["response"]["attachments"] + > = []; + + const presignedUrls: Promise[] = uploadKeys + .filter((key) => data.attachments[key] !== undefined) + .map(() => + API.post("os", "/getUploadUrl", { + body: {}, + }) + ); + const loadPresignedUrls = await Promise.all(presignedUrls); + + uploadKeys + .filter((key) => data.attachments[key] !== undefined) + .forEach((uploadKey, index) => { + const attachmenListObject = attachmentList?.find( + (item) => item.name === uploadKey + ); + const title = attachmenListObject ? attachmenListObject.label : "Other"; + const fileGroup = data.attachments[uploadKey] as File[]; + + // upload all files in this group and track there name + for (const file of fileGroup) { + uploadedFiles.push( + fetch(loadPresignedUrls[index].url, { + body: file, + method: "PUT", + }) + ); + + fileMetaData.push({ + key: loadPresignedUrls[index].key, + filename: file.name, + title: title, + bucket: loadPresignedUrls[index].bucket, + uploadDate: Date.now(), + }); + } + }); + + await Promise.all(uploadedFiles); + + const dataToSubmit = { + id: id!, + additionalInformation: data?.additionalInformation ?? null, + attachments: fileMetaData, + responseDate: timestamp, + submitterEmail: user?.user?.email ?? "N/A", + submitterName: + `${user?.user?.given_name} ${user?.user?.family_name}` ?? "N/A", + }; + + try { + await API.post("os", `/action/${Action.RESPOND_TO_RAI}`, { + body: dataToSubmit, + }); + setSuccessModalIsOpen(true); + } catch (err) { + console.log(err); + setErrorModalIsOpen(true); + } + }; + + return ( + + + + +
+

+ Medicaid SPA Formal RAI Details +

+

+ Indicates a required field +

+ +
+ {/*-------------------------------------------------------- */} +
+

+ Package Details +

+
+ + + {id} + +
+
+ + + {item?._source.planType} + +
+
+ {/*-------------------------------------------------------- */} +
+

Attachments

+

+ Maximum file size of 80 MB per attachment.{" "} + You can add multiple files per attachment type.{" "} + Read the description for each of the attachment types on the{" "} + { + + FAQ Page + + } + . +

+
+

+ We accept the following file formats:{" "} + .docx, .jpg, .png, .pdf, .xlsx,{" "} + and a few others. See the full list on the{" "} + { + + FAQ Page + + } + . +

+
+

+ + At least one attachment is required. +

+
+ {attachmentList.map(({ name, label, required }) => ( + ( + + + {label} + {required ? : ""} + + + + + )} + /> + ))} + {/*-------------------------------------------------------- */} + ( + +

+ Additional Information +

+ + Add anything else that you would like to share with CMS. + + + 4,000 characters allowed +
+ )} + /> + {/*-------------------------------------------------------- */} + + {Object.keys(form.formState.errors).length !== 0 ? ( + + Missing or malformed information. Please see errors above. + + ) : null} + {form.formState.isSubmitting && ( +
+ +
+ )} +
+ + Submit + + setCancelModalIsOpen(true)} + className="px-12" + > + Cancel + + { + setSuccessModalIsOpen(false); + navigate(`/details?id=${id}`); + }} + onCancel={() => setSuccessModalIsOpen(false)} + title="Submission Successful" + body={ +

+ Please be aware that it may take up to a minute for your + submission to show in the Dashboard. +

+ } + cancelButtonVisible={false} + acceptButtonText="Exit to Package Details" + /> + { + setErrorModalIsOpen(false); + navigate(`/details?id=${id}`); + }} + onCancel={() => setErrorModalIsOpen(false)} + title="Submission Error" + body={ +

+ An error occurred during issue. +
+ You may close this window and try again, however, this likely + requires support. +
+
+ Please contact the{" "} + + helpdesk + {" "} + . You may include the following in your support request:{" "} +
+
+

    +
  • SPA ID: {id}
  • +
  • Timestamp: {Date.now()}
  • +
+

+ } + cancelButtonVisible={true} + cancelButtonText="Return to Form" + acceptButtonText="Exit to Package Details" + /> + { + setCancelModalIsOpen(false); + navigate(`/details?id=${id}`); + }} + onCancel={() => setCancelModalIsOpen(false)} + cancelButtonText="Return to Form" + acceptButtonText="Yes" + title="Are you sure you want to cancel?" + body={ +

+ If you leave this page you will lose your progress on this + form +

+ } + /> +
+ +
+
+ ); +}; diff --git a/src/services/ui/src/pages/actions/index.tsx b/src/services/ui/src/pages/actions/index.tsx index a99466f07c..0026702fca 100644 --- a/src/services/ui/src/pages/actions/index.tsx +++ b/src/services/ui/src/pages/actions/index.tsx @@ -2,16 +2,20 @@ import { Navigate, useParams } from "react-router-dom"; import { ROUTES } from "@/routes"; import { ToggleRaiResponseWithdraw } from "@/pages/actions/ToggleRaiResponseWithdraw"; import { IssueRai } from "@/pages/actions/IssueRai"; +import { RespondToRai } from "@/pages/actions/RespondToRai"; import { Action } from "shared-types"; export const ActionFormIndex = () => { const { type } = useParams<{ type: Action }>(); switch (type) { case Action.ENABLE_RAI_WITHDRAW: + return ; case Action.DISABLE_RAI_WITHDRAW: return ; case Action.ISSUE_RAI: return ; + case Action.RESPOND_TO_RAI: + return ; default: // TODO: Better error communication instead of navigate? // "Hey, this action doesn't exist. Click to go back to the Dashboard." diff --git a/src/services/ui/src/pages/dashboard/Lists/spas/consts.tsx b/src/services/ui/src/pages/dashboard/Lists/spas/consts.tsx index 147db76f8e..7a3e95a7c6 100644 --- a/src/services/ui/src/pages/dashboard/Lists/spas/consts.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/spas/consts.tsx @@ -41,6 +41,7 @@ export const TABLE_COLUMNS = (props?: { { field: "actionType.keyword", label: "Action Type", + visible: false, cell: (data) => data.actionType ? LABELS[data.actionType as keyof typeof LABELS] || data.actionType @@ -65,6 +66,7 @@ export const TABLE_COLUMNS = (props?: { { field: "origin", label: "Submission Source", + visible: false, cell: (data) => { if (data.origin?.toLowerCase() === "onemac") { return "OneMAC"; diff --git a/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx b/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx index 61458a4b26..56be766d13 100644 --- a/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx @@ -15,7 +15,6 @@ export const SpasList = () => { const context = useOsContext(); const params = useOsParams(); if (context.error) return ; - console.log(user, "user from spas"); const columns = TABLE_COLUMNS({ isCms: user?.isCms, user: user?.user }); diff --git a/src/services/ui/src/pages/dashboard/Lists/waivers/consts.tsx b/src/services/ui/src/pages/dashboard/Lists/waivers/consts.tsx index f71cc0a11f..27bbd8a5db 100644 --- a/src/services/ui/src/pages/dashboard/Lists/waivers/consts.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/waivers/consts.tsx @@ -66,6 +66,7 @@ export const TABLE_COLUMNS = (props?: { { field: "origin", label: "Submission Source", + visible: false, cell: (data) => { if (data.origin?.toLowerCase() === "onemac") { return "OneMAC"; diff --git a/src/services/ui/src/pages/detail/index.tsx b/src/services/ui/src/pages/detail/index.tsx index f691ab99e9..7178d08705 100644 --- a/src/services/ui/src/pages/detail/index.tsx +++ b/src/services/ui/src/pages/detail/index.tsx @@ -7,7 +7,7 @@ import { DetailsSection, ErrorAlert, LoadingSpinner, - RaiResponses, + RaiList, SubmissionInfo, } from "@/components"; import { useGetUser } from "@/api/useGetUser"; @@ -140,7 +140,7 @@ export const DetailsContent = ({ data }: { data?: ItemResult }) => { additionalInformation={data?._source.additionalInformation} /> - + ); diff --git a/src/services/ui/src/pages/form/medicaid-form.tsx b/src/services/ui/src/pages/form/medicaid-form.tsx index d28c02aa4b..968f25dae2 100644 --- a/src/services/ui/src/pages/form/medicaid-form.tsx +++ b/src/services/ui/src/pages/form/medicaid-form.tsx @@ -168,13 +168,13 @@ export const MedicaidForm = () => { attachments: fileMetaData, origin: "micro", authority: "medicaid spa", - raiResponses: [], raiWithdrawEnabled: false, submitterEmail: user?.user?.email ?? "N/A", submitterName: `${user?.user?.given_name} ${user?.user?.family_name}` ?? "N/A", proposedEffectiveDate: data.proposedEffectiveDate.getTime(), state: data.id.split("-")[0], + rais: {}, // We do not collect rai data as part of new submission. }; try { diff --git a/src/services/ui/src/utils/actionLabelMapper.ts b/src/services/ui/src/utils/actionLabelMapper.ts index 9234e3ac41..75c3a65ad5 100644 --- a/src/services/ui/src/utils/actionLabelMapper.ts +++ b/src/services/ui/src/utils/actionLabelMapper.ts @@ -7,6 +7,8 @@ export const mapActionLabel = (a: Action) => { case Action.DISABLE_RAI_WITHDRAW: return "Disable Formal RAI Response Withdraw"; case Action.ISSUE_RAI: - return "Issue RAI"; + return "Issue Formal RAI"; + case Action.RESPOND_TO_RAI: + return "Respond to Formal RAI"; } };