Skip to content

Commit

Permalink
Merge pull request #1087 from Enterprise-CMCS/main
Browse files Browse the repository at this point in the history
Release to val
  • Loading branch information
tiffanyvu authored Jan 29, 2025
2 parents 173b5b2 + 43402c9 commit f0fdc0d
Show file tree
Hide file tree
Showing 26 changed files with 1,969 additions and 103 deletions.
2 changes: 1 addition & 1 deletion lib/lambda/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
28 changes: 23 additions & 5 deletions lib/lambda/sinkChangelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
transformUpdateValuesSchema,
transformDeleteSchema,
transformedUpdateIdSchema,
transformedSplitSPASchema,
transformSubmitValuesSchema,
} from "./update/adminChangeSchemas";
import { getPackageChangelog } from "libs/api/package";

Expand Down Expand Up @@ -64,26 +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),
);
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) => {
Expand Down
6 changes: 5 additions & 1 deletion lib/lambda/sinkMainProcessors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import {
deleteAdminChangeSchema,
updateValuesAdminChangeSchema,
updateIdAdminChangeSchema,
splitSPAAdminChangeSchema,
extendSubmitNOSOAdminSchema,
} from "./update/adminChangeSchemas";

const removeDoubleQuotesSurroundingString = (str: string) => str.replace(/^"|"$/g, "");
const adminRecordSchema = deleteAdminChangeSchema
.or(updateValuesAdminChangeSchema)
.or(updateIdAdminChangeSchema);
.or(updateIdAdminChangeSchema)
.or(splitSPAAdminChangeSchema)
.or(extendSubmitNOSOAdminSchema);

type OneMacRecord = {
id: string;
Expand Down
33 changes: 33 additions & 0 deletions lib/lambda/submit/getNextSplitSPAId.ts
Original file line number Diff line number Diff line change
@@ -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}`;
};
84 changes: 84 additions & 0 deletions lib/lambda/submit/submitSplitSPA.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
});
105 changes: 105 additions & 0 deletions lib/lambda/submit/submitSplitSPA.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
}
};
54 changes: 54 additions & 0 deletions lib/lambda/update/adminChangeSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,10 +49,56 @@ export const transformUpdateValuesSchema = (offset: number) =>
timestamp: Date.now(),
}));

const currentTime = Date.now();

export const transformedUpdateIdSchema = updateIdAdminChangeSchema.transform((data) => ({
...data,
event: "update-id",
packageId: data.id,
id: `${data.id}`,
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(),
status: z.string(),
submitterEmail: z.string(),
submitterName: z.string(),
adminChangeType: z.literal("NOSO"),
mockEvent: z.string(),
changeMade: z.string(),
changeReason: z.string(),
});

export const extendSubmitNOSOAdminSchema = submitNOSOAdminSchema.extend({
packageId: z.string(),
origin: z.string(),
makoChangedDate: z.number(),
changedDate: z.number(),
statusDate: z.number(),
isAdminChange: z.boolean(),
state: z.string(),
event: z.string(),
stateStatus: z.string(),
cmsStatus: z.string(),
});

export const transformSubmitValuesSchema = extendSubmitNOSOAdminSchema.transform((data) => ({
...data,
adminChangeType: "NOSO",
event: "NOSO",
id: data.id,
packageId: data.id,
timestamp: Date.now(),
}));
Loading

0 comments on commit f0fdc0d

Please sign in to comment.