diff --git a/src/packages/shared-types/action-types/withdrawPackage.ts b/src/packages/shared-types/action-types/withdrawPackage.ts new file mode 100644 index 0000000000..596449cd21 --- /dev/null +++ b/src/packages/shared-types/action-types/withdrawPackage.ts @@ -0,0 +1,21 @@ +import { z, ZodArray } from "zod"; +import { onemacAttachmentSchema } from "../onemac"; + +export const withdrawPackageSchema = (attachmentArrayType: ZodArray) => + z.object({ + id: z.string(), + additionalInformation: z + .string() + .max(4000, "This field may only be up to 4000 characters.") + .optional(), + attachments: z.object({ + supportingDocumentation: attachmentArrayType.optional(), + }), + }); + +export const withdrawPackageEventSchema = withdrawPackageSchema( + z.array(onemacAttachmentSchema) +); +export type WithdrawPackageEventSchema = z.infer< + typeof withdrawPackageEventSchema +>; diff --git a/src/packages/shared-types/actions.ts b/src/packages/shared-types/actions.ts index acb121be08..80a02078a0 100644 --- a/src/packages/shared-types/actions.ts +++ b/src/packages/shared-types/actions.ts @@ -1,5 +1,6 @@ export enum Action { ENABLE_RAI_WITHDRAW = "enable-rai-withdraw", + WITHDRAW_PACKAGE = "withdraw-package", DISABLE_RAI_WITHDRAW = "disable-rai-withdraw", ISSUE_RAI = "issue-rai", RESPOND_TO_RAI = "respond-to-rai", diff --git a/src/packages/shared-types/index.ts b/src/packages/shared-types/index.ts index e7e882693e..63f134b6da 100644 --- a/src/packages/shared-types/index.ts +++ b/src/packages/shared-types/index.ts @@ -9,3 +9,4 @@ export * from "./actions"; export * from "./attachments"; export * from "./authority"; export * from "./action-types/withdraw-record"; +export * from "./action-types/withdrawPackage"; diff --git a/src/packages/shared-types/onemac.ts b/src/packages/shared-types/onemac.ts index 7ad853ff65..11e3add891 100644 --- a/src/packages/shared-types/onemac.ts +++ b/src/packages/shared-types/onemac.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { s3ParseUrl } from "shared-utils/s3-url-parser"; -const onemacAttachmentSchema = z.object({ +export const onemacAttachmentSchema = z.object({ s3Key: z.string().nullish(), filename: z.string(), title: z.string(), diff --git a/src/services/api/handlers/action.ts b/src/services/api/handlers/action.ts index 4d8c1978d4..26ddb8d925 100644 --- a/src/services/api/handlers/action.ts +++ b/src/services/api/handlers/action.ts @@ -12,6 +12,7 @@ import { issueRai, respondToRai, toggleRaiResponseWithdraw, + withdrawPackage } from "./packageActions"; export const handler = async (event: APIGatewayEvent) => { @@ -53,6 +54,9 @@ export const handler = async (event: APIGatewayEvent) => { // Call package action switch (actionType) { + case Action.WITHDRAW_PACKAGE: + await withdrawPackage(body); + break; case Action.ISSUE_RAI: await issueRai(body); break; diff --git a/src/services/api/handlers/getPackageActions.ts b/src/services/api/handlers/getPackageActions.ts index 991dd2f029..4c034bc4b8 100644 --- a/src/services/api/handlers/getPackageActions.ts +++ b/src/services/api/handlers/getPackageActions.ts @@ -53,6 +53,7 @@ export const packageActionsForResult = ( } } } else if (isStateUser(user)) { + actions.push(Action.WITHDRAW_PACKAGE); switch (result._source.seatoolStatus) { case SEATOOL_STATUS.PENDING_RAI: if (activeRai) { @@ -67,7 +68,6 @@ export const packageActionsForResult = ( export const getPackageActions = async (event: APIGatewayEvent) => { const body = JSON.parse(event.body) as GetPackageActionsBody; try { - console.log(body); const result = await getPackage(body.id); const passedStateAuth = await isAuthorized(event, result._source.state); if (!passedStateAuth) @@ -80,13 +80,11 @@ export const getPackageActions = async (event: APIGatewayEvent) => { statusCode: 404, body: { message: "No record found for the given id" }, }); - const authDetails = getAuthDetails(event); const userAttr = await lookupUserAttributes( authDetails.userId, authDetails.poolId ); - return response({ statusCode: 200, body: { @@ -101,5 +99,4 @@ export const getPackageActions = async (event: APIGatewayEvent) => { }); } }; - export const handler = getPackageActions; diff --git a/src/services/api/handlers/packageActions.ts b/src/services/api/handlers/packageActions.ts index 80bb9da6c7..182cae1fd3 100644 --- a/src/services/api/handlers/packageActions.ts +++ b/src/services/api/handlers/packageActions.ts @@ -12,7 +12,15 @@ const config = { database: "SEA", }; -import { Action, raiSchema, RaiSchema } from "shared-types"; +import { + Action, + raiSchema, + RaiSchema, + OneMacSink, + WithdrawPackageEventSchema, + transformOnemac, + withdrawPackageEventSchema, +} from "shared-types"; import { produceMessage } from "../libs/kafka"; import { response } from "../libs/handler"; import { SEATOOL_STATUS } from "shared-types/statusHelper"; @@ -140,8 +148,53 @@ export async function respondToRai(body: RaiSchema, rais: any) { console.log("heyo"); } -export async function withdrawPackage(id, timestamp) { +export async function withdrawPackage(body: WithdrawPackageEventSchema) { console.log("State withdrawing a package."); + // Check incoming data + const result = withdrawPackageEventSchema.safeParse(body); + if (result.success === false) { + console.error( + "Withdraw Package event validation error. The following record failed to parse: ", + JSON.stringify(body), + "Because of the following Reason(s):", + result.error.message + ); + return response({ + statusCode: 400, + body: { message: "Withdraw Package event validation error" }, + }); + } + // Begin query (data is confirmed) + const pool = await sql.connect(config); + const transaction = new sql.Transaction(pool); + const query = ` + UPDATE SEA.dbo.State_Plan + SET SPW_Status_ID = (Select SPW_Status_ID from SEA.dbo.SPW_Status where SPW_Status_DESC = '${SEATOOL_STATUS.WITHDRAWN}') + WHERE ID_Number = '${body.id}' + `; + + try { + const txnResult = await transaction.request().query(query); + console.log(txnResult); + await produceMessage( + TOPIC_NAME, + body.id, + JSON.stringify({ ...result.data, actionType: Action.WITHDRAW_PACKAGE }) + ); + // Commit transaction + await transaction.commit(); + } catch (err) { + // Rollback and log + await transaction.rollback(); + console.error("Error executing query:", err); + return response({ + statusCode: 500, + body: { message: err.message }, + }); + } finally { + // Close pool + await pool.close(); + } } export async function toggleRaiResponseWithdraw( diff --git a/src/services/api/handlers/search.ts b/src/services/api/handlers/search.ts index 1f356ea33a..f36bfdc860 100644 --- a/src/services/api/handlers/search.ts +++ b/src/services/api/handlers/search.ts @@ -13,7 +13,6 @@ export const getSearchData = async (event: APIGatewayEvent) => { if (event.body) { query = JSON.parse(event.body); } - query.query = query?.query || {}; query.query.bool = query.query?.bool || {}; query.query.bool.must = query.query.bool?.must || []; @@ -30,7 +29,6 @@ export const getSearchData = async (event: APIGatewayEvent) => { console.log(JSON.stringify(query, null, 2)); const results = await os.search(process.env.osDomain, "main", query); - return response({ statusCode: 200, body: results, diff --git a/src/services/ui/src/pages/actions/WithdrawPackage.tsx b/src/services/ui/src/pages/actions/WithdrawPackage.tsx new file mode 100644 index 0000000000..c77cc949d0 --- /dev/null +++ b/src/services/ui/src/pages/actions/WithdrawPackage.tsx @@ -0,0 +1,229 @@ +import { Navigate, useNavigate, useParams } from "react-router-dom"; +import { Button } from "@/components/Inputs"; +import { ConfirmationModal } from "@/components/Modal/ConfirmationModal"; +import { useEffect, useState } from "react"; +import { Action, ItemResult, withdrawPackageSchema } from "shared-types"; +import { FAQ_TARGET, ROUTES } from "@/routes"; +import { PackageActionForm } from "./PackageActionForm"; +import { ActionFormIntro, PackageInfo } from "./common"; +import { z } from "zod"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as I from "@/components/Inputs"; +import { Link } from "react-router-dom"; +import { Alert, LoadingSpinner } from "@/components"; +import { buildActionUrl } from "@/lib"; +import { useGetUser } from "@/api/useGetUser"; +import { useSubmissionService } from "@/api/submissionService"; + +const withdrawPackageFormSchema = withdrawPackageSchema( + z.array(z.instanceof(File)) +); +type WithdrawPackageFormSchema = z.infer; +type UploadKey = keyof WithdrawPackageFormSchema["attachments"]; +type AttachmentRecipe = { + readonly name: UploadKey; + readonly label: string; + readonly required: boolean; +}; + +const attachments: AttachmentRecipe[] = [ + { + name: "supportingDocumentation", + label: "Supporting Documentation", + required: false, + } as const, +]; + +const handler: SubmitHandler = (data) => + console.log(data); + +const WithdrawPackageForm: React.FC = ({ item }: { item?: ItemResult }) => { + const navigate = useNavigate(); + const { id, type } = useParams<{ id: string; type: Action }>(); + const { data: user } = useGetUser(); + const form = useForm({ + resolver: zodResolver(withdrawPackageFormSchema), + }); + const { mutate, isLoading, isSuccess, error } = useSubmissionService<{ + id: string; + }>({ + data: { id: id! }, + endpoint: buildActionUrl(type!), + user, + }); + + const [successModalOpen, setSuccessModalOpen] = useState(false); + const [cancelModalOpen, setCancelModalOpen] = useState(false); + + useEffect(() => { + if (isSuccess) setSuccessModalOpen(true); + }, [isSuccess]); + + if (!item) return ; // Prevents optional chains below + return ( + <> + {isLoading && } +
+
+ +

+ Complete this form to withdrawn a package. Once complete you will + not be able to resubmit tis package.CMS will be notified and will + use this content to review your request. if CMS needs any + additional information.they will follow up by email +

+
+ + +
+
+

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. +

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

+ Additional Information +

+ + Add anything else you would like to share with CMS, + limited to 4000 characters + + + + 4,000 characters allowed + +
+ )} + /> + {error && ( + + ERROR Withdrawing Package: + {error.response.data.message} + + )} +
+ + +
+ +
+
+ {/* Success Modal */} + { + setSuccessModalOpen(false); + navigate(`/details?id=${id}`); + }} + onCancel={() => setSuccessModalOpen(false)} // Should be made optional + title="Withdraw Successful" + body={ +

+ Please be aware that it may take up to a minute for your status to + change on the Dashboard and Details pages. +

+ } + cancelButtonVisible={false} + acceptButtonText="Go to Package Details" + /> + + {/* Cancel Modal */} + { + setCancelModalOpen(false); + navigate(`/details?id=${id}`); + }} + onCancel={() => setCancelModalOpen(false)} + cancelButtonText="Return to Form" + acceptButtonText="Leave Page" + title="Are you sure you want to cancel?" + body={ +

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

+ } + /> +
+ + ); +}; + +export const WithdrawPackage = () => ( + + + +); diff --git a/src/services/ui/src/pages/actions/common.tsx b/src/services/ui/src/pages/actions/common.tsx new file mode 100644 index 0000000000..3d814123ec --- /dev/null +++ b/src/services/ui/src/pages/actions/common.tsx @@ -0,0 +1,42 @@ +import { removeUnderscoresAndCapitalize } from "@/utils"; +import { PropsWithChildren } from "react"; +import { ItemResult } from "shared-types"; + +// Keeps aria stuff and classes condensed +const SectionTemplate = ({ + label, + value, +}: { + label: string; + value: string; +}) => ( +
+ + + {value} + +
+); + +export const PackageInfo = ({ item }: { item: ItemResult }) => ( +
+ + +
+); + +export const ActionFormIntro = ({ + title, + children, +}: PropsWithChildren<{ title: string }>) => ( +
+

{title}

+ {children} +
+); diff --git a/src/services/ui/src/pages/actions/index.tsx b/src/services/ui/src/pages/actions/index.tsx index 0026702fca..c298701af5 100644 --- a/src/services/ui/src/pages/actions/index.tsx +++ b/src/services/ui/src/pages/actions/index.tsx @@ -2,14 +2,16 @@ import { Navigate, useParams } from "react-router-dom"; import { ROUTES } from "@/routes"; import { ToggleRaiResponseWithdraw } from "@/pages/actions/ToggleRaiResponseWithdraw"; import { IssueRai } from "@/pages/actions/IssueRai"; +import { WithdrawPackage } from "@/pages/actions/WithdrawPackage"; import { RespondToRai } from "@/pages/actions/RespondToRai"; import { Action } from "shared-types"; export const ActionFormIndex = () => { const { type } = useParams<{ type: Action }>(); switch (type) { + case Action.WITHDRAW_PACKAGE: + return ; case Action.ENABLE_RAI_WITHDRAW: - return ; case Action.DISABLE_RAI_WITHDRAW: return ; case Action.ISSUE_RAI: @@ -17,8 +19,6 @@ export const ActionFormIndex = () => { 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." return ; } }; diff --git a/src/services/ui/src/utils/actionLabelMapper.ts b/src/services/ui/src/utils/actionLabelMapper.ts index 75c3a65ad5..67ed2e5cd2 100644 --- a/src/services/ui/src/utils/actionLabelMapper.ts +++ b/src/services/ui/src/utils/actionLabelMapper.ts @@ -8,6 +8,8 @@ export const mapActionLabel = (a: Action) => { return "Disable Formal RAI Response Withdraw"; case Action.ISSUE_RAI: return "Issue Formal RAI"; + case Action.WITHDRAW_PACKAGE: + return "Withdraw Package"; case Action.RESPOND_TO_RAI: return "Respond to Formal RAI"; }