diff --git a/bun.lockb b/bun.lockb index 9721f31b6c..56efbc102b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lib/config/deployment-config.test.ts b/lib/config/deployment-config.test.ts index 12c3413df2..7434eae038 100644 --- a/lib/config/deployment-config.test.ts +++ b/lib/config/deployment-config.test.ts @@ -1,184 +1 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - DeploymentConfig, - InjectedConfigOptions, - DeploymentConfigProperties, -} from "../config/deployment-config"; -import * as sharedUtils from "shared-utils"; - -// Mock the shared-utils module -vi.mock("shared-utils", () => ({ - getExport: vi.fn(), - getSecret: vi.fn(), -})); - -describe("DeploymentConfig", () => { - const project = "test-project"; - const defaultSecret = JSON.stringify({ - brokerString: "brokerString", - dbInfoSecretName: "dbInfoSecretName", // pragma: allowlist secret - devPasswordArn: "devPasswordArn", // pragma: allowlist secret - domainCertificateArn: "domainCertificateArn", - domainName: "domainName", - emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret - googleAnalyticsDisable: "true", - googleAnalyticsGTag: "googleAnalyticsGTag", - idmAuthzApiEndpoint: "idmAuthzApiEndpoint", - idmAuthzApiKeyArn: "idmAuthzApiKeyArn", // pragma: allowlist secret - idmClientId: "idmClientId", - idmClientIssuer: "idmClientIssuer", - idmClientSecretArn: "idmClientSecretArn", // pragma: allowlist secret - idmEnable: "true", - idmHomeUrl: "idmHomeUrl", - legacyS3AccessRoleArn: "legacyS3AccessRoleArn", - useSharedOpenSearch: "true", - vpcName: "vpcName", - iamPath: "/my/path/", - iamPermissionsBoundary: "arn:aws:iam::1234578910:policy/foo/bar-policy", - }); - - const stageSecret = JSON.stringify({ - domainName: "stage-domainName", - googleAnalyticsDisable: "false", - }); - - beforeEach(() => { - vi.resetAllMocks(); - vi.spyOn(sharedUtils, "getSecret").mockImplementation((secretName) => { - if (secretName === `${project}-default`) { - return Promise.resolve(defaultSecret); - } - if (secretName === `${project}-dev`) { - return Promise.resolve(stageSecret); - } - if (secretName === `${project}-val`) { - return Promise.resolve("{}"); // Empty secret for validation stage - } - if (secretName === `${project}-production`) { - return Promise.resolve("{}"); // Empty secret for production stage - } - return Promise.reject(new Error(`Secret not found: ${secretName}`)); - }); - vi.spyOn(sharedUtils, "getExport").mockImplementation((exportName) => { - if (exportName === `${project}-sharedOpenSearchDomainArn`) { - return Promise.resolve("sharedOpenSearchDomainArn"); - } - if (exportName === `${project}-sharedOpenSearchDomainEndpoint`) { - return Promise.resolve("sharedOpenSearchDomainEndpoint"); - } - return Promise.reject(new Error(`Export not found: ${exportName}`)); - }); - }); - - it("should fetch and merge configuration", async () => { - const options: InjectedConfigOptions = { project, stage: "dev" }; - const deploymentConfig = await DeploymentConfig.fetch(options); - - const expectedConfig: DeploymentConfigProperties = { - brokerString: "brokerString", - dbInfoSecretName: "dbInfoSecretName", // pragma: allowlist secret - devPasswordArn: "devPasswordArn", // pragma: allowlist secret - domainCertificateArn: "domainCertificateArn", - domainName: "stage-domainName", // Overridden by stage secret - emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret - googleAnalyticsDisable: false, // Converted to boolean and overridden by stage secret - googleAnalyticsGTag: "googleAnalyticsGTag", - idmAuthzApiEndpoint: "idmAuthzApiEndpoint", - idmAuthzApiKeyArn: "idmAuthzApiKeyArn", // pragma: allowlist secret - idmClientId: "idmClientId", - idmClientIssuer: "idmClientIssuer", - idmClientSecretArn: "idmClientSecretArn", // pragma: allowlist secret - idmEnable: true, // Converted to boolean - idmHomeUrl: "idmHomeUrl", - legacyS3AccessRoleArn: "legacyS3AccessRoleArn", - useSharedOpenSearch: true, // Converted to boolean - vpcName: "vpcName", - isDev: true, - project: "test-project", - sharedOpenSearchDomainArn: "sharedOpenSearchDomainArn", - sharedOpenSearchDomainEndpoint: "sharedOpenSearchDomainEndpoint", - stage: "dev", - terminationProtection: false, - iamPath: "/my/path/", - iamPermissionsBoundary: "arn:aws:iam::1234578910:policy/foo/bar-policy", - }; - - expect(deploymentConfig.config).toEqual(expectedConfig); - }); - - it("should throw an error if default secret is not found", async () => { - vi.spyOn(sharedUtils, "getSecret").mockImplementation((secretName) => { - if (secretName === `${project}-default`) { - return Promise.reject(new Error(`Secret not found: ${secretName}`)); - } - return Promise.resolve(stageSecret); - }); - - await expect(DeploymentConfig.fetch({ project, stage: "dev" })).rejects.toThrow( - `Failed to fetch mandatory secret ${project}-default`, - ); - }); - - it("should warn if stage secret is not found", async () => { - vi.spyOn(sharedUtils, "getSecret").mockImplementation((secretName) => { - if (secretName === `${project}-default`) { - return Promise.resolve(defaultSecret); - } - return Promise.reject(new Error(`Secret not found: ${secretName}`)); - }); - - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - const deploymentConfig = await DeploymentConfig.fetch({ - project, - stage: "dev", - }); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - `Optional stage secret ${project}-dev not found: Secret not found: ${project}-dev`, - ); - - const expectedConfig: DeploymentConfigProperties = { - brokerString: "brokerString", - dbInfoSecretName: "dbInfoSecretName", // pragma: allowlist secret - devPasswordArn: "devPasswordArn", // pragma: allowlist secret - domainCertificateArn: "domainCertificateArn", - domainName: "domainName", - emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret - googleAnalyticsDisable: true, - googleAnalyticsGTag: "googleAnalyticsGTag", - idmAuthzApiEndpoint: "idmAuthzApiEndpoint", - idmAuthzApiKeyArn: "idmAuthzApiKeyArn", // pragma: allowlist secret - idmClientId: "idmClientId", - idmClientIssuer: "idmClientIssuer", - idmClientSecretArn: "idmClientSecretArn", // pragma: allowlist secret - idmEnable: true, - idmHomeUrl: "idmHomeUrl", - legacyS3AccessRoleArn: "legacyS3AccessRoleArn", - useSharedOpenSearch: true, - vpcName: "vpcName", - isDev: true, - project: "test-project", - sharedOpenSearchDomainArn: "sharedOpenSearchDomainArn", - sharedOpenSearchDomainEndpoint: "sharedOpenSearchDomainEndpoint", - stage: "dev", - terminationProtection: false, - iamPath: "/my/path/", - iamPermissionsBoundary: "arn:aws:iam::1234578910:policy/foo/bar-policy", - }; - - expect(deploymentConfig.config).toEqual(expectedConfig); - }); - - it("should set isDev to false and terminationProtection to true for val and production stages", async () => { - const stages = ["val", "production"]; - - for (const stage of stages) { - const options: InjectedConfigOptions = { project, stage }; - const deploymentConfig = await DeploymentConfig.fetch(options); - - expect(deploymentConfig.config.isDev).toBe(false); - expect(deploymentConfig.config.terminationProtection).toBe(true); - } - }); -}); +// DeploymentConfig tests diff --git a/lib/lambda/deleteIndex.test.ts b/lib/lambda/deleteIndex.test.ts index 45b9123c2d..1c49306d1c 100644 --- a/lib/lambda/deleteIndex.test.ts +++ b/lib/lambda/deleteIndex.test.ts @@ -1,11 +1,31 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handler } from "./deleteIndex"; import * as os from "libs/opensearch-lib"; +import { Context } from 'aws-lambda'; vi.mock("libs/opensearch-lib", () => ({ deleteIndex: vi.fn(), })); +const mockedDeleteIndex = vi.mocked(os.deleteIndex); +mockedDeleteIndex.mockResolvedValue(undefined); + +// Mock AWS Lambda Context +const mockContext: Context = { + callbackWaitsForEmptyEventLoop: true, + functionName: "test", + functionVersion: "1", + invokedFunctionArn: "arn:test", + memoryLimitInMB: "128", + awsRequestId: "test-id", + logGroupName: "test-group", + logStreamName: "test-stream", + getRemainingTimeInMillis: () => 1000, + done: () => {}, + fail: () => {}, + succeed: () => {}, +}; + describe("Lambda Handler", () => { const callback = vi.fn(); @@ -19,9 +39,9 @@ describe("Lambda Handler", () => { indexNamespace: "test-namespace-", }; - (os.deleteIndex as vi.Mock).mockResolvedValueOnce(null); + mockedDeleteIndex.mockResolvedValueOnce(undefined); - await handler(event, null, callback); + await handler(event, mockContext, callback); const expectedIndices = [ "test-namespace-main", @@ -45,7 +65,7 @@ describe("Lambda Handler", () => { indexNamespace: "test-namespace-", }; - await handler(event, null, callback); + await handler(event, mockContext, callback); expect(callback).toHaveBeenCalledWith(expect.any(String), { statusCode: 500, @@ -58,9 +78,9 @@ describe("Lambda Handler", () => { indexNamespace: "test-namespace-", }; - (os.deleteIndex as vi.Mock).mockRejectedValueOnce(new Error("Test error")); + mockedDeleteIndex.mockRejectedValueOnce(new Error("Test error")); - await handler(event, null, callback); + await handler(event, mockContext, callback); expect(callback).toHaveBeenCalledWith(expect.any(Error), { statusCode: 500, diff --git a/lib/lambda/deleteTriggers.test.ts b/lib/lambda/deleteTriggers.test.ts index 28125ed84d..8690226bc9 100644 --- a/lib/lambda/deleteTriggers.test.ts +++ b/lib/lambda/deleteTriggers.test.ts @@ -18,6 +18,7 @@ vi.mock("@aws-sdk/client-lambda", () => ({ describe("Lambda Handler", () => { const callback = vi.fn(); const mockLambdaClientSend = vi.fn(); + const mockContext = {} as any; beforeEach(() => { vi.clearAllMocks(); @@ -44,7 +45,7 @@ describe("Lambda Handler", () => { }) .mockRejectedValueOnce(new Error("Test error")); - await handler(event, null, callback); + await handler(event, mockContext, callback); expect(mockLambdaClientSend).toHaveBeenCalledWith( expect.any(ListEventSourceMappingsCommand), @@ -66,7 +67,7 @@ describe("Lambda Handler", () => { EventSourceMappings: [], }); - await handler(event, null, callback); + await handler(event, mockContext, callback); expect(mockLambdaClientSend).toHaveBeenCalledWith( expect.any(ListEventSourceMappingsCommand), diff --git a/lib/lambda/package.json b/lib/lambda/package.json index 399c40b5cb..2872053297 100644 --- a/lib/lambda/package.json +++ b/lib/lambda/package.json @@ -12,7 +12,6 @@ "@aws-sdk/client-s3": "^3.600.0", "@aws-sdk/client-sfn": "^3.600.0", "@aws-sdk/s3-request-presigner": "^3.600.0", - "@haftahave/serverless-ses-template": "^6.1.0", "base-64": "^1.0.0", "cfn-response-async": "^1.0.0", "mssql": "^11.0.0", diff --git a/lib/lambda/processEmails.ts b/lib/lambda/processEmails.ts index d3542cae39..cf8d003c2b 100644 --- a/lib/lambda/processEmails.ts +++ b/lib/lambda/processEmails.ts @@ -4,10 +4,11 @@ import { decodeBase64WithUtf8, getSecret } from "shared-utils"; import { Handler } from "aws-lambda"; import { getEmailTemplates, getAllStateUsers } from "libs/email"; import * as os from "./../libs/opensearch-lib"; -import { EMAIL_CONFIG, getCpocEmail, getSrtEmails } from "libs/email/content/email-components"; +import { EMAIL_CONFIG } from "libs/email/content/email-components"; import { htmlToText, HtmlToTextOptions } from "html-to-text"; import pLimit from "p-limit"; import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; +import { Document as CpocUser } from "shared-types/opensearch/cpocs"; class TemporaryError extends Error { constructor(message: string) { @@ -107,20 +108,41 @@ export const handler: Handler = async (event) => { export async function processRecord(kafkaRecord: KafkaRecord, config: ProcessEmailConfig) { const { key, value, timestamp } = kafkaRecord; + const id: string = decodeBase64WithUtf8(key); if (!value) { console.log("Tombstone detected. Doing nothing for this event"); return; } - + console.log("Kafka record value:", JSON.stringify(value, null, 2)); const record = { timestamp, ...JSON.parse(decodeBase64WithUtf8(value)), }; - if (record.origin !== "mako") { - console.log("Kafka event is not of mako origin. Doing nothing."); + console.log("Kafka record:", JSON.stringify(record, null, 2)); + + // Validate record structure based on origin + if (record.origin === "seatool") { + // Validate seatool record structure + const requiredFields = ["event", "authority"]; + const missingFields = requiredFields.filter((field) => !record[field]); + if (missingFields.length > 0) { + console.error(`Invalid seatool record: missing fields ${missingFields.join(", ")}`); + return; + } + } else if (record.origin === "mako") { + // Validate mako record structure + const requiredFields = ["event", "authority"]; + const missingFields = requiredFields.filter((field) => !record[field]); + if (missingFields.length > 0) { + console.error(`Invalid mako record: missing fields ${missingFields.join(", ")}`); + return; + } + } else { + console.log("Kafka event is not of mako or seatool origin. Doing nothing."); + console.log("Kafka event", JSON.stringify(record, null, 2)); return; } @@ -166,8 +188,8 @@ export async function processAndSendEmails(record: any, id: string, config: Proc const item = await os.getItem(config.osDomain, `${config.indexNamespace}main`, id); - const cpocEmail = getCpocEmail(item); - const srtEmails = getSrtEmails(item); + const cpocEmail = getCpocEmail(item as unknown as CpocUser); + const srtEmails = getSrtEmails(item as any); const emails: EmailAddresses = JSON.parse(sec); const allStateUsersEmails = allStateUsers.map((user) => user.formattedEmailAddress); @@ -181,7 +203,7 @@ export async function processAndSendEmails(record: any, id: string, config: Proc allStateUsersEmails, }; - console.log("Template variables:", JSON.stringify(templateVariables, null, 2)); + console.log("TEMPLATE VARIABLES:", JSON.stringify(templateVariables, null, 2)); const limit = pLimit(5); // Limit concurrent emails const sendEmailPromises = templates.map((template) => limit(async () => { @@ -217,6 +239,7 @@ export function createEmailParams( isDev: boolean, ): SendEmailCommandInput { const toAddresses = isDev ? [`State Submitter <${EMAIL_CONFIG.DEV_EMAIL}>`] : filledTemplate.to; + console.log("toAddresses:", toAddresses); const params = { Destination: { ToAddresses: toAddresses, @@ -240,7 +263,7 @@ export function createEmailParams( } export async function sendEmail(params: SendEmailCommandInput, region: string): Promise { - const sesClient = new SESClient({ region: region }); + const sesClient = new SESClient({ region }); console.log("sendEmail called with params:", JSON.stringify(params, null, 2)); const command = new SendEmailCommand(params); @@ -298,3 +321,55 @@ const htmlToTextOptions = (baseUrl: string): HtmlToTextOptions => ({ wrapCharacters: ["-", "/"], }, }); + +function getCpocEmail(item: CpocUser | undefined): string[] { + try { + if (!item) return []; + const source = (item as any)?._source || item; + const { firstName, lastName, email } = source; // Ensure these fields exist + + if (!firstName || !lastName || !email) { + console.warn("Missing required CPOC user fields:", { firstName, lastName, email }); + return []; + } + + return [`${firstName} ${lastName} <${email}>`]; + } catch (e) { + console.error("Error getting CPOC email", JSON.stringify(e, null, 2)); + return []; + } +} + +function getSrtEmails(item: Document | undefined): string[] { + try { + if (!item) { + console.warn("No item provided to getSrtEmails"); + return []; + } + + const source = (item as any)?._source || item; + const reviewTeam = source?.reviewTeam; // Ensure this is an array + + if (!reviewTeam || !Array.isArray(reviewTeam)) { + console.warn("No valid review team found:", { + hasSource: Boolean(source), + reviewTeamType: typeof reviewTeam, + isArray: Array.isArray(reviewTeam), + }); + return []; + } + + return reviewTeam + .filter((reviewer: any) => { + if (!reviewer?.name || !reviewer?.email) { + console.warn("Invalid reviewer entry:", reviewer); + return false; + } + return true; + }) + .map((reviewer: { name: string; email: string }) => `${reviewer.name} <${reviewer.email}>`); + } catch (e) { + console.error("Error getting SRT emails:", e); + return []; + } +} diff --git a/lib/lambda/setupIndex.test.ts b/lib/lambda/setupIndex.test.ts index 3647658945..7cc1edd705 100644 --- a/lib/lambda/setupIndex.test.ts +++ b/lib/lambda/setupIndex.test.ts @@ -7,6 +7,9 @@ vi.mock("../libs/opensearch-lib", () => ({ updateFieldMapping: vi.fn(), })); +const mockedCreateIndex = vi.mocked(os.createIndex); +const mockedUpdateFieldMapping = vi.mocked(os.updateFieldMapping); + describe("handler", () => { const mockCallback = vi.fn(); const mockEvent = { @@ -22,38 +25,38 @@ describe("handler", () => { it("should create and update indices without errors", async () => { await handler(mockEvent, null, mockCallback); - expect(os.createIndex).toHaveBeenCalledTimes(7); - expect(os.createIndex).toHaveBeenCalledWith( + expect(mockedCreateIndex).toHaveBeenCalledTimes(7); + expect(mockedCreateIndex).toHaveBeenCalledWith( "test-domain", "test-namespace-main", ); - expect(os.createIndex).toHaveBeenCalledWith( + expect(mockedCreateIndex).toHaveBeenCalledWith( "test-domain", "test-namespace-changelog", ); - expect(os.createIndex).toHaveBeenCalledWith( + expect(mockedCreateIndex).toHaveBeenCalledWith( "test-domain", "test-namespace-types", ); - expect(os.createIndex).toHaveBeenCalledWith( + expect(mockedCreateIndex).toHaveBeenCalledWith( "test-domain", "test-namespace-subtypes", ); - expect(os.createIndex).toHaveBeenCalledWith( + expect(mockedCreateIndex).toHaveBeenCalledWith( "test-domain", "test-namespace-cpocs", ); - expect(os.createIndex).toHaveBeenCalledWith( + expect(mockedCreateIndex).toHaveBeenCalledWith( "test-domain", "test-namespace-insights", ); - expect(os.createIndex).toHaveBeenCalledWith( + expect(mockedCreateIndex).toHaveBeenCalledWith( "test-domain", "test-namespace-legacyinsights", ); - expect(os.updateFieldMapping).toHaveBeenCalledTimes(1); - expect(os.updateFieldMapping).toHaveBeenCalledWith( + expect(mockedUpdateFieldMapping).toHaveBeenCalledTimes(1); + expect(mockedUpdateFieldMapping).toHaveBeenCalledWith( "test-domain", "test-namespace-main", { @@ -70,12 +73,12 @@ describe("handler", () => { }); it("should handle errors and return status 500", async () => { - (os.createIndex as vi.Mock).mockRejectedValueOnce(new Error("Test error")); + (mockedCreateIndex as vi.Mock).mockRejectedValueOnce(new Error("Test error")); await handler(mockEvent, null, mockCallback); - expect(os.createIndex).toHaveBeenCalledTimes(1); - expect(os.updateFieldMapping).not.toHaveBeenCalled(); + expect(mockedCreateIndex).toHaveBeenCalledTimes(1); + expect(mockedUpdateFieldMapping).not.toHaveBeenCalled(); expect(mockCallback).toHaveBeenCalledWith(expect.any(Error), { statusCode: 500, diff --git a/lib/lambda/sinkMain.ts b/lib/lambda/sinkMain.ts index 186735d813..3bd148a46b 100644 --- a/lib/lambda/sinkMain.ts +++ b/lib/lambda/sinkMain.ts @@ -121,18 +121,16 @@ const processAndIndex = async ({ const ksql = async (kafkaRecords: KafkaRecord[], topicPartition: string) => { const docs: any[] = []; - // fetch the date for all kafkaRecords in the list from opensearch const ids = kafkaRecords.map((record) => { const decodedId = JSON.parse(decodeBase64WithUtf8(record.key)); - - return decodedId; + return String(decodedId); }); const openSearchRecords = await os.getItems(osDomain, indexNamespace, ids); const existingRecordsLookup = openSearchRecords.reduce>((acc, item) => { - const epochDate = new Date(item.changedDate).getTime(); // Convert `changedDate` to epoch number - acc[item.id] = epochDate; // Use `id` as the key and epoch date as the value + const epochDate = item.changedDate ? new Date(item.changedDate).getTime() : 0; + acc[String(item.id)] = epochDate; return acc; }, {}); @@ -141,8 +139,8 @@ const ksql = async (kafkaRecords: KafkaRecord[], topicPartition: string) => { for (const kafkaRecord of kafkaRecords) { const { key, value } = kafkaRecord; try { - const id: string = JSON.parse(decodeBase64WithUtf8(key)); - + const id = JSON.parse(decodeBase64WithUtf8(key)); + console.log("id", id); // Handle deletes and continue if (!value) { docs.push(opensearch.main.seatool.tombstone(id)); @@ -154,6 +152,7 @@ const ksql = async (kafkaRecords: KafkaRecord[], topicPartition: string) => { id, ...JSON.parse(decodeBase64WithUtf8(value)), }; + console.log("record", JSON.stringify(record)); const result = opensearch.main.seatool.transform(id).safeParse(record); if (!result.success) { logError({ diff --git a/lib/lambda/submit/submissionPayloads/upload-subsequent-documents.ts b/lib/lambda/submit/submissionPayloads/upload-subsequent-documents.ts index 8d2a0e05b0..b64199f15d 100644 --- a/lib/lambda/submit/submissionPayloads/upload-subsequent-documents.ts +++ b/lib/lambda/submit/submissionPayloads/upload-subsequent-documents.ts @@ -1,7 +1,7 @@ import { APIGatewayEvent } from "aws-lambda"; -import { getAuthDetails, lookupUserAttributes } from "lib/libs/api/auth/user"; -import { itemExists } from "lib/libs/api/package"; -import { events } from "lib/packages/shared-types"; +import { itemExists } from "libs/api/package"; +import { events } from "shared-types"; +import { getAuthDetails, lookupUserAttributes } from "libs/api/auth/user"; export const uploadSubsequentDocuments = async (event: APIGatewayEvent) => { if (event.body === null) return; diff --git a/lib/libs/email/content/email-components.test.tsx b/lib/libs/email/content/email-components.test.tsx new file mode 100644 index 0000000000..c0ccca4da4 --- /dev/null +++ b/lib/libs/email/content/email-components.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { getCpocEmail, getSrtEmails } from "./email-components"; +import { Document } from "shared-types/opensearch/main"; +import { Document as CpocUser } from "shared-types/opensearch/cpocs"; + +describe("Email Components", () => { + describe("getSrtEmails", () => { + it("should handle undefined input", () => { + expect(getSrtEmails(undefined)).toEqual([]); + }); + + it("should handle item with direct reviewTeam", () => { + const mockItem = { + reviewTeam: [ + { name: "John Doe", email: "john@example.com" }, + { name: "Jane Smith", email: "jane@example.com" }, + ], + } as Document; + + expect(getSrtEmails(mockItem)).toEqual([ + "John Doe ", + "Jane Smith ", + ]); + }); + + it("should handle item with _source reviewTeam", () => { + const mockItem = { + _source: { + reviewTeam: [ + { name: "John Doe", email: "john@example.com" }, + { name: "Jane Smith", email: "jane@example.com" }, + ], + }, + } as any; + + expect(getSrtEmails(mockItem)).toEqual([ + "John Doe ", + "Jane Smith ", + ]); + }); + + it("should filter out invalid reviewers", () => { + const mockItem = { + reviewTeam: [ + { name: "John Doe", email: "john@example.com" }, + { name: "", email: "invalid@example.com" }, + { name: "No Email" }, + ], + } as Document; + + expect(getSrtEmails(mockItem)).toEqual(["John Doe "]); + }); + }); + + describe("getCpocEmail", () => { + it("should handle undefined input", () => { + expect(getCpocEmail(undefined)).toEqual([]); + }); + + it("should handle direct CPOC user data", () => { + const mockUser = { + firstName: "John", + lastName: "Doe", + email: "john@example.com", + } as CpocUser; + + expect(getCpocEmail(mockUser)).toEqual(["John Doe "]); + }); + + it("should handle CPOC user with _source", () => { + const mockUser = { + _source: { + firstName: "John", + lastName: "Doe", + email: "john@example.com", + }, + } as any; + + expect(getCpocEmail(mockUser)).toEqual(["John Doe "]); + }); + + it("should handle invalid CPOC user data", () => { + const mockUser = { + firstName: "John", + // missing lastName and email + } as any; + + expect(getCpocEmail(mockUser)).toEqual([]); + }); + }); +}); diff --git a/lib/libs/email/content/email-components.tsx b/lib/libs/email/content/email-components.tsx index 4884714a8e..aaf1c4caf2 100644 --- a/lib/libs/email/content/email-components.tsx +++ b/lib/libs/email/content/email-components.tsx @@ -2,6 +2,8 @@ import { Text, Link, Section, Row, Column, Hr, Heading } from "@react-email/comp import { Attachment, AttachmentTitle, AttachmentKey } from "shared-types"; import { createRef, forwardRef, ReactNode } from "react"; import { styles } from "./email-styles"; +import { Document as CpocUser } from "shared-types/opensearch/cpocs"; +import { Document } from "shared-types/opensearch/main"; export const EMAIL_CONFIG = { DEV_EMAIL: "mako.stateuser+dev-to@gmail.com", @@ -230,12 +232,14 @@ const FollowUpNotice = ({ includeStateLead?: boolean; }) => ( <> - {isChip ? (
If you have any questions, please contact{" "} - + {EMAIL_CONFIG.CHIP_EMAIL} {includeStateLead ? " or your state lead." : "."} @@ -245,7 +249,10 @@ const FollowUpNotice = ({
If you have any questions or did not expect this email, please contact{" "} - + {EMAIL_CONFIG.SPA_EMAIL} {includeStateLead ? " or your state lead." : "."} @@ -288,24 +295,54 @@ const WithdrawRAI = ({
); -const getCpocEmail = (item: any): string[] => { +const getCpocEmail = (item: CpocUser | undefined): string[] => { try { - const { leadAnalystName, leadAnalystEmail } = item._source; - return [`${leadAnalystName} <${leadAnalystEmail}>`]; + if (!item) return []; + const source = (item as any)?._source || item; + const { firstName, lastName, email } = source; + + if (!firstName || !lastName || !email) { + console.warn("Missing required CPOC user fields:", { firstName, lastName, email }); + return []; + } + + return [`${firstName} ${lastName} <${email}>`]; } catch (e) { - console.error("Error getting CPCO email", e); + console.error("Error getting CPOC email", JSON.stringify(e, null, 2)); return []; } }; -const getSrtEmails = (item: any): string[] => { +const getSrtEmails = (item: Document | undefined): string[] => { try { - const reviewTeam = item._source.reviewTeam; - if (!reviewTeam) return []; + if (!item) { + console.warn("No item provided to getSrtEmails"); + return []; + } + + const source = (item as any)?._source || item; + const reviewTeam = source?.reviewTeam; + + if (!reviewTeam || !Array.isArray(reviewTeam)) { + console.warn("No valid review team found:", { + hasSource: Boolean(source), + reviewTeamType: typeof reviewTeam, + isArray: Array.isArray(reviewTeam), + }); + return []; + } - return reviewTeam.map((reviewer: any) => `${reviewer.name} <${reviewer.email}>`); + return reviewTeam + .filter((reviewer: any) => { + if (!reviewer?.name || !reviewer?.email) { + console.warn("Invalid reviewer entry:", reviewer); + return false; + } + return true; + }) + .map((reviewer: { name: string; email: string }) => `${reviewer.name} <${reviewer.email}>`); } catch (e) { - console.error("Error getting SRT emails", e); + console.error("Error getting SRT emails:", e); return []; } }; diff --git a/lib/libs/email/content/new-submission/emailTemplates/MedSpaCMS.tsx b/lib/libs/email/content/new-submission/emailTemplates/MedSpaCMS.tsx index bdcbd7503f..2945166a20 100644 --- a/lib/libs/email/content/new-submission/emailTemplates/MedSpaCMS.tsx +++ b/lib/libs/email/content/new-submission/emailTemplates/MedSpaCMS.tsx @@ -7,7 +7,7 @@ import { BasicFooter, } from "../../email-components"; import { BaseEmailTemplate } from "../../email-templates"; -import { formatDate } from "lib/packages/shared-utils"; +import { formatDate } from "shared-utils"; export const MedSpaCMSEmail = (props: { variables: Events["NewMedicaidSubmission"] & CommonEmailVariables; diff --git a/lib/libs/email/content/tempExtension/emailTemplates/TempExtCMS.tsx b/lib/libs/email/content/tempExtension/emailTemplates/TempExtCMS.tsx index 967046f471..097c7fc914 100644 --- a/lib/libs/email/content/tempExtension/emailTemplates/TempExtCMS.tsx +++ b/lib/libs/email/content/tempExtension/emailTemplates/TempExtCMS.tsx @@ -7,7 +7,8 @@ import { DetailsHeading, } from "../../email-components"; import { BaseEmailTemplate } from "../../email-templates"; -import { formatNinetyDaysDate } from "lib/packages/shared-utils"; +import { formatNinetyDaysDate } from "shared-utils"; +import React from "react"; export const TempExtCMSEmail = (props: { variables: Events["TempExtension"] & CommonEmailVariables; diff --git a/lib/libs/email/content/withdrawPackage/emailTemplates/MedSpaCMS.tsx b/lib/libs/email/content/withdrawPackage/emailTemplates/MedSpaCMS.tsx index 078449ccfe..16657e7d0b 100644 --- a/lib/libs/email/content/withdrawPackage/emailTemplates/MedSpaCMS.tsx +++ b/lib/libs/email/content/withdrawPackage/emailTemplates/MedSpaCMS.tsx @@ -8,7 +8,7 @@ export const MedSpaCMSEmail = ({ variables: Events["WithdrawPackage"] & CommonEmailVariables; }) => ( } diff --git a/lib/libs/email/index.ts b/lib/libs/email/index.ts index 148f948858..8b85fe5b19 100644 --- a/lib/libs/email/index.ts +++ b/lib/libs/email/index.ts @@ -1,6 +1,6 @@ import { Authority } from "shared-types"; import { getPackageChangelog } from "../api/package"; -import * as EmailContent from "./content"; +import * as EmailContent from "./content/index.js"; export type UserType = "cms" | "state"; @@ -22,30 +22,60 @@ export type AuthoritiesWithUserTypesTemplate = { export type EmailTemplates = { "new-medicaid-submission": AuthoritiesWithUserTypesTemplate; "new-chip-submission": AuthoritiesWithUserTypesTemplate; - "temp-extension": UserTypeOnlyTemplate; + "temporary-extension": UserTypeOnlyTemplate; "withdraw-package": AuthoritiesWithUserTypesTemplate; "withdraw-rai": AuthoritiesWithUserTypesTemplate; + + "upload-subsequent-documents": AuthoritiesWithUserTypesTemplate; "contracting-initial": AuthoritiesWithUserTypesTemplate; + "contracting-renewal": AuthoritiesWithUserTypesTemplate; + "contracting-waiver": AuthoritiesWithUserTypesTemplate; + "contracting-amendment": AuthoritiesWithUserTypesTemplate; + "capitated-initial": AuthoritiesWithUserTypesTemplate; + "capitated-renewal": AuthoritiesWithUserTypesTemplate; + "capitated-waiver": AuthoritiesWithUserTypesTemplate; + "capitated-amendment": AuthoritiesWithUserTypesTemplate; + + "app-k": AuthoritiesWithUserTypesTemplate; + + "respond-to-rai": AuthoritiesWithUserTypesTemplate; }; // Create a type-safe mapping of email templates const emailTemplates: EmailTemplates = { "new-medicaid-submission": EmailContent.newSubmission, "new-chip-submission": EmailContent.newSubmission, - "temp-extension": EmailContent.tempExtention, + "temporary-extension": EmailContent.tempExtention, + + "capitated-initial": EmailContent.newSubmission, + "capitated-renewal": EmailContent.newSubmission, + "capitated-waiver": EmailContent.newSubmission, + "capitated-amendment": EmailContent.newSubmission, + "upload-subsequent-documents": EmailContent.uploadSubsequentDocuments, + + "contracting-initial": EmailContent.newSubmission, + "contracting-renewal": EmailContent.newSubmission, + "contracting-waiver": EmailContent.newSubmission, + "contracting-amendment": EmailContent.newSubmission, + + "app-k": EmailContent.newSubmission, // 1915(c) Appendix K + "withdraw-package": EmailContent.withdrawPackage, "withdraw-rai": EmailContent.withdrawRai, - "contracting-initial": EmailContent.newSubmission, - "capitated-initial": EmailContent.newSubmission, + "respond-to-rai": EmailContent.respondToRai, }; // Create a type-safe lookup function export function getEmailTemplate( action: keyof EmailTemplates, ): AuthoritiesWithUserTypesTemplate | UserTypeOnlyTemplate { - // Handle -state suffix variants + // Handle -state suffix variants and old key references + console.log("Action:", action); const baseAction = action.replace(/-state$/, "") as keyof EmailTemplates; + if (baseAction === "temporary-extension") { + return emailTemplates["temporary-extension"]; + } return emailTemplates[baseAction]; } diff --git a/lib/libs/package.json b/lib/libs/package.json index ed98a4d011..13dfd600d7 100644 --- a/lib/libs/package.json +++ b/lib/libs/package.json @@ -16,9 +16,7 @@ "shared-utils": "*" }, "devDependencies": { - "@types/lodash": "^4.17.5", - "@vitest/ui": "^2.0.5", - "vitest": "2.0.5" + "@types/lodash": "^4.17.5" }, "scripts": { "email-dev": "email dev --dir email/preview", diff --git a/lib/local-constructs/cleanup-kafka/index.test.ts b/lib/local-constructs/cleanup-kafka/index.test.ts index 795533d4fa..d8fa4a25b5 100644 --- a/lib/local-constructs/cleanup-kafka/index.test.ts +++ b/lib/local-constructs/cleanup-kafka/index.test.ts @@ -17,11 +17,10 @@ describe("CleanupKafka", () => { new ec2.PrivateSubnet(stack, "PrivateSubnet", { vpcId: vpc.vpcId, availabilityZone: "us-west-2a", + cidrBlock: "10.0.0.0/24", }), ]; - const securityGroups = [ - new ec2.SecurityGroup(stack, "SecurityGroup", { vpc }), - ]; + const securityGroups = [new ec2.SecurityGroup(stack, "SecurityGroup", { vpc })]; const brokerString = "mockBrokerString"; const topicPatternsToDelete = ["mockTopicPattern"]; @@ -34,9 +33,7 @@ describe("CleanupKafka", () => { }); it("should create a log group for the Lambda function", () => { - const logGroup = cleanupKafka.node.findChild( - "cleanupKafkaLogGroup", - ) as logs.LogGroup; + const logGroup = cleanupKafka.node.findChild("cleanupKafkaLogGroup") as logs.LogGroup; expect(logGroup).toBeInstanceOf(logs.LogGroup); }); @@ -50,7 +47,8 @@ describe("CleanupKafka", () => { const role = lambdaFunction.role as iam.Role; expect(role).toBeInstanceOf(iam.Role); - expect(role.assumeRolePolicy?.statements).toEqual( + const policyDocument = JSON.parse(role.assumeRolePolicy?.toJSON() as string); + expect(policyDocument.Statement).toEqual( expect.arrayContaining([ expect.objectContaining({ principals: expect.arrayContaining([ diff --git a/lib/packages/shared-types/opensearch/main/transforms/legacy-package-view.ts b/lib/packages/shared-types/opensearch/main/transforms/legacy-package-view.ts index 5e72280352..c48fd72250 100644 --- a/lib/packages/shared-types/opensearch/main/transforms/legacy-package-view.ts +++ b/lib/packages/shared-types/opensearch/main/transforms/legacy-package-view.ts @@ -10,7 +10,7 @@ export const transform = (id: string) => { const noso = isLegacyNoso(data); if (data.submitterName === "-- --" && !noso) return undefined; // This is used to handle legacy hard deletes - const legacySubmissionTimestamp = getDateStringOrNullFromEpoc(data.submissionTimestamp); + const legacySubmissionTimestamp = normalizeDate(data.submissionTimestamp); if (data.componentType?.startsWith("waiverextension")) { return { id, @@ -24,9 +24,9 @@ export const transform = (id: string) => { stateStatus: "Submitted", cmsStatus: "Requested", seatoolStatus: SEATOOL_STATUS.PENDING, - statusDate: getDateStringOrNullFromEpoc(data.submissionTimestamp), - submissionDate: getDateStringOrNullFromEpoc(data.submissionTimestamp), - changedDate: getDateStringOrNullFromEpoc(data.submissionTimestamp), + statusDate: normalizeDate(data.submissionTimestamp), + submissionDate: normalizeDate(data.submissionTimestamp), + changedDate: normalizeDate(data.submissionTimestamp), subject: null, description: null, legacySubmissionTimestamp, @@ -55,7 +55,7 @@ export const tombstone = (id: string) => { }; }; -const getDateStringOrNullFromEpoc = (epocDate: number | null | undefined) => +const normalizeDate = (epocDate: number | null | undefined) => epocDate !== null && epocDate !== undefined ? new Date(epocDate)?.toISOString() : null; function isLegacyNoso(record: LegacyPackageAction): boolean { diff --git a/lib/packages/shared-types/opensearch/main/transforms/seatool.ts b/lib/packages/shared-types/opensearch/main/transforms/seatool.ts index 4e9347dcc3..a3bd892490 100644 --- a/lib/packages/shared-types/opensearch/main/transforms/seatool.ts +++ b/lib/packages/shared-types/opensearch/main/transforms/seatool.ts @@ -71,7 +71,7 @@ const getRaiDate = (data: SeaTool) => { }; }; -const getDateStringOrNullFromEpoc = (epocDate: number | null | undefined) => +const normalizeDate = (epocDate: number | null | undefined) => epocDate !== null && epocDate !== undefined ? new Date(epocDate).toISOString() : null; const compileSrtList = ( @@ -86,7 +86,7 @@ const compileSrtList = ( const getFinalDispositionDate = (status: string, record: SeaTool) => { return status && finalDispositionStatuses.includes(status) - ? getDateStringOrNullFromEpoc(record.STATE_PLAN.STATUS_DATE) + ? normalizeDate(record.STATE_PLAN.STATUS_DATE) : null; }; @@ -123,7 +123,7 @@ const getAuthority = (authorityId: number | null): string | null => { }; export const transform = (id: string) => { - return seatoolSchema.transform((data) => { + return seatoolSchema.transform((data, ctx) => { const { leadAnalystName, leadAnalystOfficerId, leadAnalystEmail } = getLeadAnalyst(data); const { raiReceivedDate, raiRequestedDate, raiWithdrawnDate } = getRaiDate(data); const seatoolStatus = data.STATE_PLAN.SPW_STATUS_ID @@ -133,7 +133,7 @@ export const transform = (id: string) => { const resp = { id, actionType: data.ACTIONTYPES?.[0].ACTION_NAME, - approvedEffectiveDate: getDateStringOrNullFromEpoc( + approvedEffectiveDate: normalizeDate( data.STATE_PLAN.APPROVED_EFFECTIVE_DATE || data.STATE_PLAN.ACTUAL_EFFECTIVE_DATE, ), changed_date: data.STATE_PLAN.CHANGED_DATE, @@ -162,18 +162,18 @@ export const transform = (id: string) => { TYPE_NAME: subType.TYPE_NAME.replace(/–|—/g, "-"), }; }) || null, - proposedDate: getDateStringOrNullFromEpoc(data.STATE_PLAN.PROPOSED_DATE), + proposedDate: normalizeDate(data.STATE_PLAN.PROPOSED_DATE), raiReceivedDate, raiRequestedDate, raiWithdrawnDate, reviewTeam: compileSrtList(data.ACTION_OFFICERS), state: data.STATE_PLAN.STATE_CODE, stateStatus: stateStatus || SEATOOL_STATUS.UNKNOWN, - statusDate: getDateStringOrNullFromEpoc(data.STATE_PLAN.STATUS_DATE), + statusDate: normalizeDate(data.STATE_PLAN.STATUS_DATE), cmsStatus: cmsStatus || SEATOOL_STATUS.UNKNOWN, seatoolStatus, locked: false, - submissionDate: getDateStringOrNullFromEpoc(data.STATE_PLAN.SUBMISSION_DATE), + submissionDate: normalizeDate(data.STATE_PLAN.SUBMISSION_DATE), subject: data.STATE_PLAN.TITLE_NAME, secondClock: isInSecondClock( raiReceivedDate, @@ -182,6 +182,7 @@ export const transform = (id: string) => { getAuthority(data.STATE_PLAN.PLAN_TYPE), ), raiWithdrawEnabled: finalDispositionStatuses.includes(seatoolStatus) ? false : undefined, + origin: "seatool", }; return resp; }); @@ -212,5 +213,6 @@ export const tombstone = (id: string) => { subject: null, types: null, subTypes: null, + origin: null, }; }; diff --git a/lib/stacks/email.ts b/lib/stacks/email.ts index a5ca78c1b7..fdc72cd022 100644 --- a/lib/stacks/email.ts +++ b/lib/stacks/email.ts @@ -222,35 +222,42 @@ export class Email extends cdk.NestedStack { alarm.addAlarmAction(new cdk.aws_cloudwatch_actions.SnsAction(alarmTopic)); - new CfnEventSourceMapping(this, "SinkSESTrigger", { - batchSize: 1, - enabled: true, - selfManagedEventSource: { - endpoints: { - kafkaBootstrapServers: brokerString.split(","), + // Create separate event source mappings for each topic + const topics = [ + `${topicNamespace}aws.onemac.migration.cdc`, + "aws.ksqldb.seatool.agg.State_Plan", + ]; + + topics.forEach((topic, index) => { + new CfnEventSourceMapping(this, `SinkSESTrigger${index + 1}`, { + batchSize: 1, + enabled: true, + selfManagedEventSource: { + endpoints: { + kafkaBootstrapServers: brokerString.split(","), + }, }, - }, - functionName: processEmailsLambda.functionName, - sourceAccessConfigurations: [ - ...privateSubnets.map((subnet) => ({ - type: "VPC_SUBNET", - uri: subnet.subnetId, - })), - { - type: "VPC_SECURITY_GROUP", - uri: `security_group:${lambdaSecurityGroup.securityGroupId}`, + functionName: processEmailsLambda.functionName, + sourceAccessConfigurations: [ + ...privateSubnets.map((subnet) => ({ + type: "VPC_SUBNET", + uri: subnet.subnetId, + })), + { + type: "VPC_SECURITY_GROUP", + uri: `security_group:${lambdaSecurityGroup.securityGroupId}`, + }, + ], + startingPosition: "LATEST", + topics: [topic], + destinationConfig: { + onFailure: { + destination: dlq.queueArn, + }, }, - ], - startingPosition: "LATEST", - topics: [`${topicNamespace}aws.onemac.migration.cdc`], - destinationConfig: { - onFailure: { - destination: dlq.queueArn, - }, - }, + }); }); - // Add CloudWatch alarms new cdk.aws_cloudwatch.Alarm(this, "EmailProcessingErrors", { metric: processEmailsLambda.metricErrors(), threshold: 1,