diff --git a/lib/lambda/search.test.ts b/lib/lambda/search.test.ts index 4c0624557..7a52b3089 100644 --- a/lib/lambda/search.test.ts +++ b/lib/lambda/search.test.ts @@ -29,7 +29,7 @@ describe("getSearchData Handler", () => { const body = JSON.parse(res.body); expect(body).toBeTruthy(); expect(body?.hits?.hits).toBeTruthy(); - expect(body?.hits?.hits?.length).toEqual(17); + expect(body?.hits?.hits?.length).toEqual(19); }); it("should handle errors during processing", async () => { diff --git a/lib/lambda/sinkChangelog.ts b/lib/lambda/sinkChangelog.ts index 99da28e8b..c2e665202 100644 --- a/lib/lambda/sinkChangelog.ts +++ b/lib/lambda/sinkChangelog.ts @@ -6,6 +6,7 @@ import { transformUpdateValuesSchema, transformDeleteSchema, transformedUpdateIdSchema, + transformedSplitSPASchema, transformSubmitValuesSchema, } from "./update/adminChangeSchemas"; import { getPackageChangelog } from "libs/api/package"; @@ -65,28 +66,42 @@ const processAndIndex = async ({ // Parse the kafka record's value const record = JSON.parse(decodeBase64WithUtf8(value)); - // query all changelog entries for this ID and create copies of all entries with new ID if (record.isAdminChange) { - const schema = transformDeleteSchema(offset).or( - transformUpdateValuesSchema(offset) - .or(transformedUpdateIdSchema) - .or(transformSubmitValuesSchema), - ); + const schema = transformDeleteSchema(offset) + .or(transformUpdateValuesSchema(offset)) + .or(transformedUpdateIdSchema) + .or(transformedSplitSPASchema) + .or(transformSubmitValuesSchema); const result = schema.safeParse(record); if (result.success) { if (result.data.adminChangeType === "update-id") { + // Push doc with package being soft deleted docs.forEach((log) => { const recordOffset = log.id.split("-").at(-1); - docs.push({ ...log, id: `${result.data.id}-${recordOffset}`, packageId: result.data.id, }); }); + // Get all changelog entries for this ID and create copies of all entries with new ID + const packageChangelogs = await getPackageChangelog(result.data.idToBeUpdated); + + packageChangelogs.hits.hits.forEach((log) => { + const recordOffset = log._id.split("-").at(-1); + docs.push({ + ...log._source, + id: `${result.data.id}-${recordOffset}`, + packageId: result.data.id, + }); + }); + } else if (result.data.adminChangeType === "split-spa") { + // Push doc with new split package + docs.push(result.data); + // Get all changelog entries for this ID and create copies of all entries with new ID const packageChangelogs = await getPackageChangelog(result.data.idToBeUpdated); packageChangelogs.hits.hits.forEach((log) => { diff --git a/lib/lambda/sinkMainProcessors.ts b/lib/lambda/sinkMainProcessors.ts index 3a00a2e67..3b7c713e3 100644 --- a/lib/lambda/sinkMainProcessors.ts +++ b/lib/lambda/sinkMainProcessors.ts @@ -7,6 +7,7 @@ import { deleteAdminChangeSchema, updateValuesAdminChangeSchema, updateIdAdminChangeSchema, + splitSPAAdminChangeSchema, extendSubmitNOSOAdminSchema, } from "./update/adminChangeSchemas"; @@ -14,6 +15,7 @@ const removeDoubleQuotesSurroundingString = (str: string) => str.replace(/^"|"$/ const adminRecordSchema = deleteAdminChangeSchema .or(updateValuesAdminChangeSchema) .or(updateIdAdminChangeSchema) + .or(splitSPAAdminChangeSchema) .or(extendSubmitNOSOAdminSchema); type OneMacRecord = { diff --git a/lib/lambda/submit/getNextSplitSPAId.ts b/lib/lambda/submit/getNextSplitSPAId.ts new file mode 100644 index 000000000..4d72e6ade --- /dev/null +++ b/lib/lambda/submit/getNextSplitSPAId.ts @@ -0,0 +1,33 @@ +import { search } from "libs/opensearch-lib"; +import { getDomainAndNamespace } from "libs/utils"; +import { cpocs } from "lib/packages/shared-types/opensearch"; + +export const getNextSplitSPAId = async (spaId: string) => { + const { domain, index } = getDomainAndNamespace("main"); + const query = { + size: 50, + query: { + regexp: { + "id.keyword": `${spaId}-[A-Z]`, + }, + }, + }; + // Get existing split SPAs for this package id + const { hits } = await search(domain, index, query); + // Extract suffixes from existing split SPA IDs + // If there are no split SPAs yet, start at the ASCII character before "A" ("@") + // Convert to ASCII char codes to get latest suffix + const latestSuffixCharCode = hits.hits.reduce((maxCharCode: number, hit: cpocs.ItemResult) => { + const suffix = hit._source.id.toString().split("-").at(-1) ?? "@"; + return Math.max(maxCharCode, suffix.charCodeAt(0)); + }, "@".charCodeAt(0)); + + // Increment letter but not past "Z" + // "A-Z" is 65-90 in ASCII + if (latestSuffixCharCode >= 90) { + throw new Error("This package can't be further split."); + } + const nextSuffix = String.fromCharCode(latestSuffixCharCode + 1); + + return `${spaId}-${nextSuffix}`; +}; diff --git a/lib/lambda/submit/submitSplitSPA.test.ts b/lib/lambda/submit/submitSplitSPA.test.ts new file mode 100644 index 000000000..39474d966 --- /dev/null +++ b/lib/lambda/submit/submitSplitSPA.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handler } from "./submitSplitSPA"; +import { APIGatewayEvent } from "node_modules/shared-types"; +import { + getRequestContext, + TEST_CHIP_SPA_ITEM, + TEST_MED_SPA_ITEM, + TEST_SPA_ITEM_TO_SPLIT, +} from "mocks"; + +describe("handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.topicName = "test-topic"; + }); + + it("should return 400 if event body is missing", async () => { + const event = {} as APIGatewayEvent; + + const result = await handler(event); + + expect(result?.statusCode).toEqual(400); + }); + + it("should return 404 if package ID is not found", async () => { + const invalidPackage = { + body: JSON.stringify({ packageId: "MD-25-9999" }), + } as unknown as APIGatewayEvent; + + const result = await handler(invalidPackage); + + expect(result?.statusCode).toEqual(404); + }); + + it("should throw an error if not Medicaid SPA", async () => { + const chipSPAPackage = { + body: JSON.stringify({ packageId: TEST_CHIP_SPA_ITEM._id }), + requestContext: getRequestContext(), + } as APIGatewayEvent; + + const result = await handler(chipSPAPackage); + + expect(result.body).toEqual(JSON.stringify({ message: "Record must be a Medicaid SPA" })); + }); + + it("should return 400 if package ID not provided", async () => { + const invalidPackage = { + body: JSON.stringify({}), + } as unknown as APIGatewayEvent; + + const result = await handler(invalidPackage); + + expect(result?.statusCode).toEqual(400); + }); + + it("should fail to split a package with no topic name", async () => { + delete process.env.topicName; + + const noActionevent = { + body: JSON.stringify({ + packageId: TEST_MED_SPA_ITEM._id, + }), + } as APIGatewayEvent; + + await expect(handler(noActionevent)).rejects.toThrow("Topic name is not defined"); + }); + + it("should create a split SPA", async () => { + const medSPAPackage = { + body: JSON.stringify({ packageId: TEST_MED_SPA_ITEM._id }), + } as unknown as APIGatewayEvent; + + const result = await handler(medSPAPackage); + expect(result?.statusCode).toEqual(200); + }); + + it("should fail if unable to get next split SPA suffix", async () => { + const medSPAPackage = { + body: JSON.stringify({ packageId: TEST_SPA_ITEM_TO_SPLIT }), + } as unknown as APIGatewayEvent; + + await expect(handler(medSPAPackage)).rejects.toThrow("This package can't be further split."); + }); +}); diff --git a/lib/lambda/submit/submitSplitSPA.ts b/lib/lambda/submit/submitSplitSPA.ts new file mode 100644 index 000000000..831bbd7cb --- /dev/null +++ b/lib/lambda/submit/submitSplitSPA.ts @@ -0,0 +1,105 @@ +import { response } from "libs/handler-lib"; +import { APIGatewayEvent } from "aws-lambda"; +import { getPackage } from "libs/api/package"; +import { produceMessage } from "libs/api/kafka"; +import { ItemResult } from "shared-types/opensearch/main"; +import { events } from "shared-types/events"; +import { getNextSplitSPAId } from "./getNextSplitSPAId"; +import { z } from "zod"; + +/* +EXAMPLE EVENT JSON: +{ + "body": { + "packageId": "MD-25-9999", + } +} +*/ + +const sendSubmitSplitSPAMessage = async (currentPackage: ItemResult) => { + const topicName = process.env.topicName as string; + if (!topicName) { + throw new Error("Topic name is not defined"); + } + const newId = await getNextSplitSPAId(currentPackage._id); + if (!newId) { + throw new Error("Error getting next Split SPA Id"); + } + + // ID and changeMade are excluded; the rest of the object has to be spread into the new package + const { + id: _id, + changeMade: _changeMade, + origin: _origin, + ...remainingFields + } = currentPackage._source; + + await produceMessage( + topicName, + newId, + JSON.stringify({ + id: newId, + idToBeUpdated: currentPackage._id, + ...remainingFields, + makoChangedDate: Date.now(), + changedDate: Date.now(), + origin: "OneMAC", + changeMade: "OneMAC Admin has added a package to OneMAC.", + changeReason: "Per request from CMS, this package was added to OneMAC.", + mockEvent: "new-medicaid-submission", + isAdminChange: true, + adminChangeType: "split-spa", + }), + ); + + return response({ + statusCode: 200, + body: { message: `New Medicaid Split SPA ${newId} has been created.` }, + }); +}; + +const splitSPAEventBodySchema = z.object({ + packageId: events["new-medicaid-submission"].baseSchema.shape.id, +}); + +export const handler = async (event: APIGatewayEvent) => { + if (!event.body) { + return response({ + statusCode: 400, + body: { message: "Event body required" }, + }); + } + try { + const body = typeof event.body === "string" ? JSON.parse(event.body) : event.body; + const { packageId } = splitSPAEventBodySchema.parse(body); + + const currentPackage = await getPackage(packageId); + if (!currentPackage || currentPackage.found == false) { + return response({ + statusCode: 404, + body: { message: "No record found for the given id" }, + }); + } + + if (currentPackage._source.authority !== "Medicaid SPA") { + return response({ + statusCode: 400, + body: { message: "Record must be a Medicaid SPA" }, + }); + } + + return sendSubmitSplitSPAMessage(currentPackage); + } catch (err) { + console.error("Error has occured modifying package:", err); + if (err instanceof z.ZodError) { + return response({ + statusCode: 400, + body: { message: err.errors }, + }); + } + return response({ + statusCode: 500, + body: { message: err.message || "Internal Server Error" }, + }); + } +}; diff --git a/lib/lambda/update/adminChangeSchemas.ts b/lib/lambda/update/adminChangeSchemas.ts index c9a8e24fd..f13bc10fd 100644 --- a/lib/lambda/update/adminChangeSchemas.ts +++ b/lib/lambda/update/adminChangeSchemas.ts @@ -23,6 +23,14 @@ export const updateIdAdminChangeSchema = z }) .and(z.record(z.string(), z.any())); +export const splitSPAAdminChangeSchema = z + .object({ + id: z.string(), + adminChangeType: z.literal("split-spa"), + idToBeUpdated: z.string(), + }) + .and(z.record(z.string(), z.any())); + export const transformDeleteSchema = (offset: number) => deleteAdminChangeSchema.transform((data) => ({ ...data, @@ -41,6 +49,8 @@ export const transformUpdateValuesSchema = (offset: number) => timestamp: Date.now(), })); +const currentTime = Date.now(); + export const transformedUpdateIdSchema = updateIdAdminChangeSchema.transform((data) => ({ ...data, event: "update-id", @@ -49,6 +59,16 @@ export const transformedUpdateIdSchema = updateIdAdminChangeSchema.transform((da timestamp: Date.now(), })); +export const transformedSplitSPASchema = splitSPAAdminChangeSchema.transform((data) => ({ + ...data, + event: "split-spa", + packageId: data.id, + id: `${data.id}`, + timestamp: currentTime, + makoChangedDate: currentTime, + changedDate: currentTime, +})); + export const submitNOSOAdminSchema = z.object({ id: z.string(), authority: z.string(), diff --git a/lib/lambda/update/updatePackage.ts b/lib/lambda/update/updatePackage.ts index 62eba97ad..038ba8760 100644 --- a/lib/lambda/update/updatePackage.ts +++ b/lib/lambda/update/updatePackage.ts @@ -101,14 +101,12 @@ const sendUpdateIdMessage = async ({ origin: _origin, ...remainingFields } = currentPackage._source; - if (updatedId === currentPackage._id) { return response({ statusCode: 400, body: { message: "New ID required to update package" }, }); } - // check if a package with this new ID already exists const packageExists = await getPackage(updatedId); if (packageExists?.found) { @@ -132,6 +130,7 @@ const sendUpdateIdMessage = async ({ } await sendDeleteMessage(currentPackage._id); + await produceMessage( topicName, updatedId, diff --git a/lib/packages/shared-types/opensearch/changelog/index.ts b/lib/packages/shared-types/opensearch/changelog/index.ts index bb86f9e5e..94bcdf48f 100644 --- a/lib/packages/shared-types/opensearch/changelog/index.ts +++ b/lib/packages/shared-types/opensearch/changelog/index.ts @@ -83,6 +83,7 @@ export type Document = Omit & | "update-values" | "update-id" | "delete" + | "split-spa" | "NOSO"; }; diff --git a/lib/stacks/api.ts b/lib/stacks/api.ts index 30613e0cb..cf43450d7 100644 --- a/lib/stacks/api.ts +++ b/lib/stacks/api.ts @@ -293,6 +293,17 @@ export class Api extends cdk.NestedStack { indexNamespace, }, }, + { + id: "submitSplitSPA", + entry: join(__dirname, "../lambda/submit/submitSplitSPA.ts"), + environment: { + topicName, + brokerString, + osDomain: `https://${openSearchDomainEndpoint}`, + indexNamespace, + }, + provisionedConcurrency: 2, + }, { id: "submitNOSO", entry: join(__dirname, "../lambda/update/submitNOSO.ts"), diff --git a/mocks/data/items.ts b/mocks/data/items.ts index 4db213073..aff5c6723 100644 --- a/mocks/data/items.ts +++ b/mocks/data/items.ts @@ -13,6 +13,8 @@ export const NOT_FOUND_ITEM_ID = "MD-0004.R00.00"; export const NOT_EXISTING_ITEM_ID = "MD-11-0000"; export const TEST_ITEM_ID = "MD-0005.R01.00"; export const TEST_SPA_ITEM_ID = "MD-11-2020"; +export const TEST_SPA_ITEM_TO_SPLIT = "MD-12-2020"; +export const TEST_SPLIT_SPA_ITEM_ID = "MD-12-2020-Z"; export const EXISTING_ITEM_TEMPORARY_EXTENSION_ID = "MD-0005.R01.TE00"; export const HI_TEST_ITEM_ID = "HI-0000.R00.00"; export const CAPITATED_INITIAL_ITEM_ID = "SS-2234.R00.00"; @@ -181,6 +183,52 @@ const items: Record = { authority: "Medicaid SPA", }, }, + [TEST_SPA_ITEM_TO_SPLIT]: { + _id: TEST_SPA_ITEM_TO_SPLIT, + found: true, + _source: { + id: TEST_SPA_ITEM_TO_SPLIT, + seatoolStatus: SEATOOL_STATUS.APPROVED, + actionType: "New", + state: "MD", + origin: "OneMAC", + changedDate: "2024-11-26T18:17:21.557Z", + changelog: [ + { + _id: `${TEST_SPA_ITEM_TO_SPLIT}-001`, + _source: { + id: `${TEST_SPA_ITEM_TO_SPLIT}-0001`, + event: "new-medicaid-submission", + packageId: TEST_SPA_ITEM_TO_SPLIT, + }, + }, + ], + authority: "Medicaid SPA", + }, + }, + [TEST_SPLIT_SPA_ITEM_ID]: { + _id: TEST_SPLIT_SPA_ITEM_ID, + found: true, + _source: { + id: TEST_SPLIT_SPA_ITEM_ID, + seatoolStatus: SEATOOL_STATUS.APPROVED, + actionType: "New", + state: "MD", + origin: "OneMAC", + changedDate: "2024-11-26T18:17:21.557Z", + changelog: [ + { + _id: `${TEST_SPLIT_SPA_ITEM_ID}-001`, + _source: { + id: `${TEST_SPLIT_SPA_ITEM_ID}-0001`, + event: "new-medicaid-submission", + packageId: TEST_SPLIT_SPA_ITEM_ID, + }, + }, + ], + authority: "Medicaid SPA", + }, + }, [EXISTING_ITEM_TEMPORARY_EXTENSION_ID]: { _id: EXISTING_ITEM_TEMPORARY_EXTENSION_ID, found: true, diff --git a/mocks/handlers/opensearch/main.ts b/mocks/handlers/opensearch/main.ts index 08e529580..ec4dd7c1a 100644 --- a/mocks/handlers/opensearch/main.ts +++ b/mocks/handlers/opensearch/main.ts @@ -58,6 +58,24 @@ const defaultOSMainSearchHandler = http.post( const must = query?.bool?.must; const mustTerms = getTermKeys(must); + const regexpQueries = query?.regexp; + + let itemHits: TestItemResult[] = Object.values(items) || []; + + if (regexpQueries) { + for (const fieldName in regexpQueries) { + const filteredFieldName = fieldName.replace(".keyword", "") as keyof TestItemResult; + const regexPattern = String(regexpQueries[fieldName]); + const regex = new RegExp(regexPattern); + + itemHits = itemHits.filter((item) => { + const fieldValue = + item[filteredFieldName] ?? + item._source?.[filteredFieldName as keyof typeof item._source]; + return fieldValue && regex.test(String(fieldValue)); + }); + } + } const appkParentIdValue = getTermValues(must, "appkParentId.keyword") || getTermValues(must, "appkParentId"); @@ -119,8 +137,6 @@ const defaultOSMainSearchHandler = http.post( } } - let itemHits: TestItemResult[] = Object.values(items) || []; - if (itemHits.length > 0) { mustTerms.forEach((term) => { const filterValue = getTermValues(must, term); diff --git a/mocks/index.d.ts b/mocks/index.d.ts index 22fd3398b..8233fef1f 100644 --- a/mocks/index.d.ts +++ b/mocks/index.d.ts @@ -134,6 +134,7 @@ export type SearchQueryBody = { query?: { bool: BoolQuery; match_all?: MatchAllQuery; + regexp?: Record; }; aggs?: Record; size?: number; diff --git a/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx b/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx index 2331d4d52..4e6ec7da9 100644 --- a/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx +++ b/react-app/src/features/forms/post-submission/upload-subsequent-documents/index.tsx @@ -109,6 +109,10 @@ export const UploadSubsequentDocuments = () => { originalSubmissionEvent = submission._source.mockEvent; } + if (originalSubmissionEvent === "split-spa") { + originalSubmissionEvent = submission._source.mockEvent; + } + const schema: SchemaWithEnforcableProps | undefined = formSchemas[originalSubmissionEvent]; if (schema === undefined) { diff --git a/react-app/src/features/package/admin-changes/index.tsx b/react-app/src/features/package/admin-changes/index.tsx index 36c84c16f..777dae239 100644 --- a/react-app/src/features/package/admin-changes/index.tsx +++ b/react-app/src/features/package/admin-changes/index.tsx @@ -49,7 +49,6 @@ export const AC_LegacyAdminChange: FC = (props) = export const AC_Update: FC = () => { return

Coming Soon

; }; - export const AdminChange: FC = (props) => { const [label, Content] = useMemo(() => { switch (props.event) { @@ -63,6 +62,8 @@ export const AdminChange: FC = (props) => { return [props.changeType || "Package Added", AC_LegacyAdminChange]; case "legacy-admin-change": return [props.changeType || "Manual Update", AC_LegacyAdminChange]; + case "split-spa": + return ["Package Added", AC_LegacyAdminChange]; default: return [BLANK_VALUE, AC_Update]; }