diff --git a/src/packages/shared-types/index.ts b/src/packages/shared-types/index.ts index 5a470581d1..8774689af9 100644 --- a/src/packages/shared-types/index.ts +++ b/src/packages/shared-types/index.ts @@ -4,4 +4,5 @@ export * from "./errors"; export * from "./seatool"; export * from "./onemac"; export * from "./opensearch"; +export * from "./uploads"; export * from "./actions"; diff --git a/src/packages/shared-types/onemac.ts b/src/packages/shared-types/onemac.ts index 83b4dd5997..f8d7df779f 100644 --- a/src/packages/shared-types/onemac.ts +++ b/src/packages/shared-types/onemac.ts @@ -2,11 +2,14 @@ import { z } from "zod"; import { s3ParseUrl } from "shared-utils/s3-url-parser"; const onemacAttachmentSchema = z.object({ - s3Key: z.string(), + s3Key: z.string().nullish(), filename: z.string(), title: z.string(), - contentType: z.string(), - url: z.string().url(), + contentType: z.string().nullish(), + url: z.string().url().nullish(), + bucket: z.string().nullish(), + key: z.string().nullish(), + uploadDate: z.number().nullish(), }); export const onemacSchema = z.object({ @@ -30,13 +33,30 @@ export const transformOnemac = (id: string) => { id, attachments: data.attachments?.map((attachment) => { - const uploadDate = parseInt(attachment.s3Key.split("/")[0]); - const parsedUrl = s3ParseUrl(attachment.url); - if (!parsedUrl) return null; - const { bucket, key } = parsedUrl; + // 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 { - ...attachment, + title: attachment.title, + filename: attachment.filename, uploadDate, bucket, key, @@ -49,8 +69,10 @@ export const transformOnemac = (id: string) => { submissionTimestamp: response.submissionTimestamp, attachments: response.attachments?.map((attachment) => { - const uploadDate = parseInt(attachment.s3Key.split("/")[0]); - const parsedUrl = s3ParseUrl(attachment.url); + const uploadDate = parseInt( + attachment.s3Key?.split("/")[0] || "0" + ); + const parsedUrl = s3ParseUrl(attachment.url || ""); if (!parsedUrl) return null; const { bucket, key } = parsedUrl; diff --git a/src/packages/shared-types/uploads.ts b/src/packages/shared-types/uploads.ts new file mode 100644 index 0000000000..65afdb647f --- /dev/null +++ b/src/packages/shared-types/uploads.ts @@ -0,0 +1,86 @@ +export type FileTypeInfo = { + extension: string; + description: string; + mime: string; +}; + +export const FILE_TYPES: FileTypeInfo[] = [ + { extension: ".bmp", description: "Bitmap Image File", mime: "image/bmp" }, + { + extension: ".csv", + description: "Comma-separated Values", + mime: "text/csv", + }, + { + extension: ".doc", + description: "MS Word Document", + mime: "application/msword", + }, + { + extension: ".docx", + description: "MS Word Document (xml)", + mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + { + extension: ".gif", + description: "Graphics Interchange Format", + mime: "image/gif", + }, + { + extension: ".jpeg", + description: "Joint Photographic Experts Group", + mime: "image/jpeg", + }, + { + extension: ".odp", + description: "OpenDocument Presentation (OpenOffice)", + mime: "application/vnd.oasis.opendocument.presentation", + }, + { + extension: ".ods", + description: "OpenDocument Spreadsheet (OpenOffice)", + mime: "application/vnd.oasis.opendocument.spreadsheet", + }, + { + extension: ".odt", + description: "OpenDocument Text (OpenOffice)", + mime: "application/vnd.oasis.opendocument.text", + }, + { + extension: ".png", + description: "Portable Network Graphic", + mime: "image/png", + }, + { + extension: ".pdf", + description: "Portable Document Format", + mime: "application/pdf", + }, + { + extension: ".ppt", + description: "MS Powerpoint File", + mime: "application/vnd.ms-powerpoint", + }, + { + extension: ".pptx", + description: "MS Powerpoint File (xml)", + mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + }, + { + extension: ".rtf", + description: "Rich Text Format", + mime: "application/rtf", + }, + { extension: ".tif", description: "Tagged Image Format", mime: "image/tiff" }, + { extension: ".txt", description: "Text File Format", mime: "text/plain" }, + { + extension: ".xls", + description: "MS Excel File", + mime: "application/vnd.ms-excel", + }, + { + extension: ".xlsx", + description: "MS Excel File (xml)", + mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, +]; diff --git a/src/services/api/handlers/getAttachmentUrl.ts b/src/services/api/handlers/getAttachmentUrl.ts index 8747890cc5..f8f486ee4a 100644 --- a/src/services/api/handlers/getAttachmentUrl.ts +++ b/src/services/api/handlers/getAttachmentUrl.ts @@ -69,7 +69,12 @@ export const handler = async (event: APIGatewayEvent) => { } // Now we can generate the presigned url - const url = await generateLegacyPresignedS3Url(body.bucket, body.key, 60); + const url = await generatePresignedUrl( + body.bucket, + body.key, + body.filename, + 60 + ); return response({ statusCode: 200, @@ -112,7 +117,12 @@ async function getClient(bucket) { } } -async function generateLegacyPresignedS3Url(bucket, key, expirationInSeconds) { +async function generatePresignedUrl( + bucket, + key, + filename, + expirationInSeconds +) { // Get an S3 client const client = await getClient(bucket); @@ -120,6 +130,7 @@ async function generateLegacyPresignedS3Url(bucket, key, expirationInSeconds) { const getObjectCommand = new GetObjectCommand({ Bucket: bucket, Key: key, + ResponseContentDisposition: `filename ="${filename}"`, }); // Generate a presigned URL diff --git a/src/services/api/handlers/submit.ts b/src/services/api/handlers/submit.ts index a4f05c5b3b..bd60a40857 100644 --- a/src/services/api/handlers/submit.ts +++ b/src/services/api/handlers/submit.ts @@ -15,6 +15,23 @@ const config = { database: "SEA", }; +import { Kafka, KafkaMessage } from "kafkajs"; +import { OneMacSink, transformOnemac } from "shared-types"; + +const kafka = new Kafka({ + clientId: "submit", + brokers: process.env.brokerString.split(","), + retry: { + initialRetryTime: 300, + retries: 8, + }, + ssl: { + rejectUnauthorized: false, + }, +}); + +const producer = kafka.producer(); + export const submit = async (event: APIGatewayEvent) => { try { const body = JSON.parse(event.body); @@ -27,10 +44,20 @@ export const submit = async (event: APIGatewayEvent) => { }); } - const pool = await sql.connect(config); + if (body.authority !== "medicaid spa") { + return response({ + statusCode: 400, + body: { + message: + "The Mako Submissions API only supports Medicaid SPA at this time", + }, + }); + } + const pool = await sql.connect(config); + console.log(body); const query = ` - Insert into SEA.dbo.State_Plan (ID_Number, State_Code, Region_ID, Plan_Type, Submission_Date, Status_Date, SPW_Status_ID, Budget_Neutrality_Established_Flag) + Insert into SEA.dbo.State_Plan (ID_Number, State_Code, Region_ID, Plan_Type, Submission_Date, Status_Date, Proposed_Date, SPW_Status_ID, Budget_Neutrality_Established_Flag) values ('${body.id}' ,'${body.state}' ,(Select Region_ID from SEA.dbo.States where State_Code = '${ @@ -41,6 +68,9 @@ export const submit = async (event: APIGatewayEvent) => { }') ,dateadd(s, convert(int, left(${Date.now()}, 10)), cast('19700101' as datetime)) ,dateadd(s, convert(int, left(${Date.now()}, 10)), cast('19700101' as datetime)) + ,dateadd(s, convert(int, left(${ + body.proposedEffectiveDate + }, 10)), cast('19700101' as datetime)) ,(Select SPW_Status_ID from SEA.dbo.SPW_Status where SPW_Status_DESC = 'Pending') ,0) `; @@ -50,10 +80,29 @@ export const submit = async (event: APIGatewayEvent) => { await pool.close(); - return response({ - statusCode: 200, - body: { message: "success" }, - }); + const message: OneMacSink = body; + const makoBody = transformOnemac(body.id).safeParse(message); + if (makoBody.success === false) { + // handle + console.log( + "MAKO Validation Error. The following record failed to parse: ", + JSON.stringify(message), + "Because of the following Reason(s): ", + makoBody.error.message + ); + } else { + console.log(message); + await produceMessage( + process.env.topicName, + body.id, + JSON.stringify(message) + ); + + return response({ + statusCode: 200, + body: { message: "success" }, + }); + } } catch (error) { console.error({ error }); return response({ @@ -63,4 +112,30 @@ export const submit = async (event: APIGatewayEvent) => { } }; +async function produceMessage(topic, key, value) { + console.log("about to connect"); + await producer.connect(); + console.log("connected"); + + const message: KafkaMessage = { + key: key, + value: value, + partition: 0, + headers: { source: "micro" }, + }; + console.log(message); + + try { + await producer.send({ + topic, + messages: [message], + }); + console.log("Message sent successfully"); + } catch (error) { + console.error("Error sending message:", error); + } finally { + await producer.disconnect(); + } +} + export const handler = submit; diff --git a/src/services/data/handlers/sink.ts b/src/services/data/handlers/sink.ts index 0892548479..c0bd9c5d46 100644 --- a/src/services/data/handlers/sink.ts +++ b/src/services/data/handlers/sink.ts @@ -106,10 +106,11 @@ export const onemac: Handler = async (event) => { const id: string = decode(key); const record = { id, ...JSON.parse(decode(value)) }; if ( - record && - record.sk === "Package" && - record.submitterName && - record.submitterName !== "-- --" // these records did not originate from onemac, thus we ignore them + record && // testing if we have a record + (record.origin === "micro" || // testing if this is a micro record + (record.sk === "Package" && // testing if this is a legacy onemac package record + record.submitterName && + record.submitterName !== "-- --")) ) { const result = transformOnemac(id).safeParse(record); if (result.success === false) { diff --git a/src/services/ui/package.json b/src/services/ui/package.json index d1a8b6de3f..c4cfd325de 100644 --- a/src/services/ui/package.json +++ b/src/services/ui/package.json @@ -18,9 +18,10 @@ "@aws-amplify/auth": "^5.4.0", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@fontsource/open-sans": "^5.0.17", "@heroicons/react": "^2.0.17", "@hookform/error-message": "^2.0.1", - "@hookform/resolvers": "^3.3.1", + "@hookform/resolvers": "^3.3.2", "@mui/lab": "^5.0.0-alpha.136", "@mui/material": "^5.14.1", "@mui/styled-engine": "^5.13.2", @@ -50,11 +51,12 @@ "file-saver": "^2.0.5", "framer-motion": "^10.16.1", "jszip": "^3.10.1", - "lucide-react": "^0.268.0", + "lucide-react": "^0.291.0", "lz-string": "^1.5.0", "react": "^18.2.0", "react-day-picker": "^8.8.1", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.46.2", "react-loader-spinner": "^5.3.4", "react-router-dom": "^6.10.0", diff --git a/src/services/ui/src/api/getAttachmentUrl.ts b/src/services/ui/src/api/getAttachmentUrl.ts index 646478b7ba..daa79283b8 100644 --- a/src/services/ui/src/api/getAttachmentUrl.ts +++ b/src/services/ui/src/api/getAttachmentUrl.ts @@ -3,13 +3,15 @@ import { API } from "aws-amplify"; export const getAttachmentUrl = async ( id: string, bucket: string, - key: string + key: string, + filename: string ) => { const response = await API.post("os", "/getAttachmentUrl", { body: { id, bucket, key, + filename, }, }); return response.url as string; diff --git a/src/services/ui/src/components/AttachmentsList/index.tsx b/src/services/ui/src/components/AttachmentsList/index.tsx index 0be73e2891..64ae02d129 100644 --- a/src/services/ui/src/components/AttachmentsList/index.tsx +++ b/src/services/ui/src/components/AttachmentsList/index.tsx @@ -23,11 +23,8 @@ type AttachmentList = { uploadDate: number; bucket: string; key: string; - s3Key: string; filename: string; title: string; - contentType: string; - url: string; } | null)[] | null; }; @@ -38,20 +35,7 @@ const handleDownloadAll = async (data: AttachmentList) => { (attachment): attachment is NonNullable => attachment !== null ); - - if (validAttachments.length > 0) { - const attachmentPromises = validAttachments.map(async (attachment) => { - const url = await getAttachmentUrl( - data.id, - attachment.bucket, - attachment.key - ); - return { ...attachment, url }; - }); - - const resolvedAttachments = await Promise.all(attachmentPromises); - downloadAll(resolvedAttachments, data.id); - } + downloadAll(validAttachments, data.id); } }; @@ -84,7 +68,8 @@ export const Attachmentslist = (data: AttachmentList) => { const url = await getAttachmentUrl( data.id, attachment.bucket, - attachment.key + attachment.key, + attachment.filename ); console.log(url); window.open(url); @@ -146,8 +131,16 @@ async function downloadAll( attachments .map(async (attachment) => { if (!attachment) return null; + let url = ""; try { - const resp = await fetch(attachment.url); + url = await getAttachmentUrl( + id, + attachment.bucket, + attachment.key, + attachment.filename + ); + + const resp = await fetch(url); if (!resp.ok) throw resp; return { filename: attachment.filename, @@ -156,7 +149,7 @@ async function downloadAll( }; } catch (e) { console.error( - `Failed to download file: ${attachment.filename} ${attachment.url}`, + `Failed to download file: ${attachment.filename} ${url}`, e ); } @@ -165,7 +158,7 @@ async function downloadAll( )) as { filename: string; title: string; contents: Blob }[]; const zip = new JSZip(); for (const { filename, title, contents } of downloadList) { - zip.file(filename, contents, { comment: title }); + zip.file(filename, contents, { comment: title, date: new Date() }); } saveAs(await zip.generateAsync({ type: "blob" }), `${id || "onemac"}.zip`); } diff --git a/src/services/ui/src/components/BreadCrumb/bread-crumb-config.ts b/src/services/ui/src/components/BreadCrumb/bread-crumb-config.ts index a37635349d..75123de8c4 100644 --- a/src/services/ui/src/components/BreadCrumb/bread-crumb-config.ts +++ b/src/services/ui/src/components/BreadCrumb/bread-crumb-config.ts @@ -44,6 +44,11 @@ export const BREAD_CRUMB_CONFIG_NEW_SUBMISSION: BreadCrumbConfig[] = [ to: ROUTES.CHIP_SPA_SUB_OPTIONS, order: 4, }, + { + displayText: "Submit New Medicaid SPA", + to: ROUTES.MEDICAID_NEW, + order: 5, + }, { displayText: "CHIP Eligibility SPAs", to: ROUTES.CHIP_ELIGIBILITY_LANDING, diff --git a/src/services/ui/src/components/Inputs/date-picker.tsx b/src/services/ui/src/components/Inputs/date-picker.tsx new file mode 100644 index 0000000000..f85c207a99 --- /dev/null +++ b/src/services/ui/src/components/Inputs/date-picker.tsx @@ -0,0 +1,47 @@ +"use client"; + +import * as React from "react"; +import { format } from "date-fns"; +import { Calendar as CalendarIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/Inputs/button"; +import { Calendar } from "@/components/Inputs/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/Popover"; + +type DatePickerProps = { + date: Date | undefined; + onChange: (date: Date | undefined) => void; +}; + +export const DatePicker = ({ date, onChange }: DatePickerProps) => { + const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); + + return ( + + + + + + { + onChange(date); + setIsCalendarOpen(false); + }} + initialFocus + /> + + + ); +}; diff --git a/src/services/ui/src/components/Inputs/form.tsx b/src/services/ui/src/components/Inputs/form.tsx index 5160e2f643..f3ae142c85 100644 --- a/src/services/ui/src/components/Inputs/form.tsx +++ b/src/services/ui/src/components/Inputs/form.tsx @@ -95,7 +95,7 @@ const FormLabel = React.forwardRef< return (