Skip to content

Commit

Permalink
Merge pull request #449 from Enterprise-CMCS/master
Browse files Browse the repository at this point in the history
Release to val
  • Loading branch information
mdial89f authored Mar 19, 2024
2 parents e7c0dc5 + 7318884 commit 125c4fa
Show file tree
Hide file tree
Showing 122 changed files with 4,919 additions and 3,168 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ testcafe_results
yarn-error.log
.serverless
.webpack
.repack
.yarn_install
tsconfig.tsbuildinfo
build_run
Expand Down
1 change: 0 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ testcafe_results
yarn-error.log
.serverless
.webpack
.repack
.yarn_install
tsconfig.tsbuildinfo
build_run
Expand Down
5 changes: 5 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"tabWidth": 2,
"useTabs": false,
"trailingComma": "all"
}
33 changes: 31 additions & 2 deletions docs/docs/services/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,40 @@ title: email
parent: Services
---

# data
# email
{: .no_toc }

## Summary
The email service deploys the lambdas, SNS topics, and Configuration Sets needed to send email.

## Detail
AWS SES is an account-wide service for basic sending and receiving of email. By creating lambdas to build the emails and sending the email with a branch-specific configuration set, we can follow the events of email sending and take action based on those events.
AWS SES is an account-wide service for basic sending and receiving of email. By creating lambdas to build the emails and sending the email with a branch-specific configuration set, we can follow the events of email sending and take action based on those events.

### Secrets Manager
The workflow will not successfully deploy unless the emailAddressLookup object is defined:

Named {project}/default/emailAddressLookup or {project}/{stage}/emailAddressLookup
{
"sourceEmail":"\"CMS MACPro no-reply\" <[email protected]>",
"osgEmail":"\"OSG\" <[email protected]>",
"chipInbox":"\"CHIP Inbox\" <[email protected]>",
"chipCcList":"\"CHIP CC 1\" <[email protected]>;\"CHIP CC 2\" <[email protected]>",
"dpoEmail":"\"DPO Action\" <[email protected]>",
"dmcoEmail":"\"DMCO Action\" <[email protected]>",
"dhcbsooEmail":"\"DHCBSOO Action\" <[email protected]>"
}

These values are set during deployment as environment variables on the lambda. You can edit these values in the AWS Console on the Lambda configuration tab.

LAUCH NOTE!!! The defined email addresses have been stored as om/default/emailAddressLookup in the production account, with om/production/emailAddressLookup overwriting those email addresses with the test email addresses. Delete the om/production/emailAddressLookup before the real launch deploy (you can also edit the environment variables after the lambda is built).

### Test accounts
There are gmail accounts created to facilitate email testing. Please contact a MACPro team member for access to these inboxes. At this time, there is only one available email inbox.
- [email protected] - a state user account - does have an associated OneMAC login

### Templates
The email services uses the serverless-ses-template plugin to manage the email templates being used for each stage. To edit the templates, edit index.js in ./ses-email-templates. Each template configuration object requires:
- name: the template name (note, the stage name is appended to this during deployment so branch templates remain unique to that stage). At this time, the naming standard for email templates is based on the event details. Specifically, the action and the authority values from the decoded event. If action is not included in the event data, "new-submission" is assumed.
- subject: the subject line of the email, may contain replacement values using {{name}}.
- html: the email body in html, may contain replacement values using {{name}}.
- text: the email body in text, may contain replacement values using {{name}}.
9 changes: 7 additions & 2 deletions serverless-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ services:
path: src/services/data
params:
ECSFailureTopicArn: ${alerts.ECSFailureTopicArn}
email:
path: src/services/email
uploads:
path: src/services/uploads
ui-infra:
Expand Down Expand Up @@ -44,3 +42,10 @@ services:
CognitoUserPoolId: ${auth.UserPoolId}
CognitoUserPoolClientId: ${auth.UserPoolClientId}
CognitoUserPoolClientDomain: ${auth.UserPoolClientDomain}
email:
path: src/services/email
params:
ApplicationEndpointUrl: ${ui-infra.ApplicationEndpointUrl}
osDomainArn: ${data.OpenSearchDomainArn}
osDomain: ${data.OpenSearchDomainEndpoint}
CognitoUserPoolId: ${auth.UserPoolId}
92 changes: 68 additions & 24 deletions src/libs/opensearch-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,31 +37,75 @@ export async function updateData(host: string, indexObject: any) {
var response = await client.update(indexObject);
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

interface Document {
id: string;
[key: string]: any;
}

export async function bulkUpdateData(
host: string,
index: opensearch.Index,
arrayOfDocuments: any
) {
// Skip if no documents have been supplied
index: string,
arrayOfDocuments: Document[],
): Promise<void> {
if (arrayOfDocuments.length === 0) {
console.log("No documents to update. Skipping bulk update operation.");
return;
}

client = client || (await getClient(host));
var response = await client.helpers.bulk({
datasource: arrayOfDocuments,
onDocument(doc: any) {
// The update operation always requires a tuple to be returned, with the
// first element being the action and the second being the update options.
return [
{
update: { _index: index, _id: doc.id },
},
{ doc_as_upsert: true },
];
},
});
console.log(response);

const body: any[] = arrayOfDocuments.flatMap((doc) => [
{ update: { _index: index, _id: doc.id } }, // Action and metadata
{ doc: doc, doc_as_upsert: true }, // Document to update or upsert
]);

async function attemptBulkUpdate(
retries: number = 5,
delay: number = 1000,
): Promise<void> {
try {
const response = await client.bulk({ refresh: true, body: body });
if (response.body.errors) {
// Check for 429 status within response errors
const hasRateLimitErrors = response.body.items.some(
(item: any) => item.update.status === 429,
);

if (hasRateLimitErrors && retries > 0) {
console.log(`Rate limit exceeded, retrying in ${delay}ms...`);
await sleep(delay);
return attemptBulkUpdate(retries - 1, delay * 2); // Exponential backoff
} else if (!hasRateLimitErrors) {
// Handle or throw other errors normally
console.error(
"Bulk update errors:",
JSON.stringify(response.body.items, null, 2),
);
throw "ERROR: Bulk update had an error that was not rate related.";
}
} else {
console.log("Bulk update successful.");
}
} catch (error: any) {
if (error.statusCode === 429 && retries > 0) {
console.log(
`Rate limit exceeded, retrying in ${delay}ms...`,
error.message,
);
await sleep(delay);
return attemptBulkUpdate(retries - 1, delay * 2); // Exponential backoff
} else {
console.error("An error occurred:", error);
throw error;
}
}
}

await attemptBulkUpdate();
}

export async function deleteIndex(host: string, index: opensearch.Index) {
Expand All @@ -84,7 +128,7 @@ export async function mapRole(
host: string,
masterRoleToAssume: string,
osRoleName: string,
iamRoleName: string
iamRoleName: string,
) {
try {
const sts = new STSClient({
Expand All @@ -95,7 +139,7 @@ export async function mapRole(
RoleArn: masterRoleToAssume,
RoleSessionName: "RoleMappingSession",
ExternalId: "foo",
})
}),
);
const interceptor = aws4Interceptor({
options: {
Expand All @@ -117,7 +161,7 @@ export async function mapRole(
path: "/and_backend_roles",
value: [iamRoleName],
},
]
],
);
return patchResponse.data;
} catch (error) {
Expand All @@ -129,7 +173,7 @@ export async function mapRole(
export async function search(
host: string,
index: opensearch.Index,
query: any
query: any,
) {
client = client || (await getClient(host));
try {
Expand All @@ -146,7 +190,7 @@ export async function search(
export async function getItem(
host: string,
index: opensearch.Index,
id: string
id: string,
) {
client = client || (await getClient(host));
try {
Expand Down Expand Up @@ -174,7 +218,7 @@ export async function createIndex(host: string, index: opensearch.Index) {
export async function updateFieldMapping(
host: string,
index: opensearch.Index,
properties: object
properties: object,
) {
client = client || (await getClient(host));
try {
Expand Down
2 changes: 0 additions & 2 deletions src/libs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
"version": "0.0.0",
"dependencies": {
"@aws-sdk/client-cognito-identity-provider": "^3.350.0",
"@aws-sdk/client-dynamodb": "^3.281.0",
"@aws-sdk/credential-provider-node": "^3.369.0",
"@aws-sdk/client-secrets-manager": "^3.410.0",
"@aws-sdk/util-dynamodb": "^3.281.0",
"@opensearch-project/opensearch": "^2.3.0",
"@types/aws4": "^1.11.3",
"aws4": "^1.12.0",
Expand Down
11 changes: 11 additions & 0 deletions src/packages/shared-types/action-types/new-submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@ export const onemacSchema = z.object({
seaActionType: z.string().optional(), // Used by waivers and chip spas
origin: z.string(),
appkParentId: z.string().nullable().default(null),
originalWaiverNumber: z.string().nullable().default(null),
additionalInformation: z.string().nullable().default(null),
submitterName: z.string(),
submitterEmail: z.string(),
attachments: z.array(attachmentSchema).nullish(),
raiWithdrawEnabled: z.boolean().default(false),
notificationMetadata: z
.object({
proposedEffectiveDate: z.number().nullish(),
submissionDate: z.number().nullish(),
})
.nullish(),
// these are specific to TEs... should be broken into its own schema
statusDate: z.number().optional(),
submissionDate: z.number().optional(),
changedDate: z.number().optional(),
});

export type OneMac = z.infer<typeof onemacSchema>;
1 change: 1 addition & 0 deletions src/packages/shared-types/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum Action {
WITHDRAW_RAI = "withdraw-rai",
WITHDRAW_PACKAGE = "withdraw-package",
REMOVE_APPK_CHILD = "remove-appk-child",
TEMP_EXTENSION = "temporary-extension",
}

export type ActionRule = {
Expand Down
6 changes: 4 additions & 2 deletions src/packages/shared-types/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { s3ParseUrl } from "shared-utils/s3-url-parser";
import { Authority } from "./authority";

export const attachmentTitleMap = (
authority: Authority
authority: Authority,
): Record<string, string> => ({
// SPA
cmsForm179: "CMS Form 179",
Expand Down Expand Up @@ -40,6 +40,8 @@ export const attachmentTitleMap = (
"1915(b)(4) FFS Selective Contracting (Streamlined) Waiver Application Pre-print",
b4IndependentAssessment:
"1915(b)(4) FFS Selective Contracting (Streamlined) Independent Assessment (first two renewals only)",
appk: "1915(c) Appendix K Amendment Waiver Template",
waiverExtensionRequest: "Waiver Extension Request",
});
export type AttachmentKey = keyof typeof attachmentTitleMap;
export type AttachmentTitle = (typeof attachmentTitleMap)[AttachmentKey];
Expand All @@ -64,7 +66,7 @@ export const legacyAttachmentSchema = z.object({
export type LegacyAttachment = z.infer<typeof legacyAttachmentSchema>;

export function handleLegacyAttachment(
attachment: LegacyAttachment
attachment: LegacyAttachment,
): Attachment | null {
const parsedUrl = s3ParseUrl(attachment.url || "");
if (!parsedUrl) return null;
Expand Down
40 changes: 34 additions & 6 deletions src/packages/shared-types/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ export interface FormSchema {

export type RHFSlotProps = {
name: string;
label?: string;
label?: RHFTextField;
labelStyling?: string;
formItemStyling?: string;
groupNamePrefix?: string;
description?: string;
description?: RHFTextField;
descriptionAbove?: boolean;
descriptionStyling?: string;
dependency?: DependencyRule;
Expand All @@ -33,17 +33,44 @@ export type RHFSlotProps = {
[K in keyof RHFComponentMap]: {
rhf: K;
props?: RHFComponentMap[K];
text?: K extends "TextDisplay" ? RHFTextField : never;
fields?: K extends "FieldArray"
? RHFSlotProps[]
: K extends "FieldGroup"
? RHFSlotProps[]
: never;
? RHFSlotProps[]
: never;
};
}[keyof RHFComponentMap];

export type RHFTextField =
| Array<
| {
text: string;
type?: RHFTextItemType;
link?: string;
listType?: "ordered" | "unordered";
list?: RHFTextListItem[];
classname?: string;
}
| string
>
| string;

export type RHFTextListItem = {
text: string;
list?: RHFTextListItem[];
listType?: "ordered" | "unordered";
classname?: string;
type?: RHFTextItemType;
link?: string;
};

type RHFTextItemType = "br" | "brWrap" | "link" | "bold" | "italic" | "list";

export type RHFOption = {
label: string;
value: string;
styledLabel?: RHFTextField;
dependency?: DependencyRule;
form?: FormGroup[];
slots?: RHFSlotProps[];
Expand Down Expand Up @@ -75,6 +102,7 @@ export type RHFComponentMap = {
appendText?: string;
removeText?: string;
};
TextDisplay: { className?: string };
};

export type FormGroup = {
Expand All @@ -97,7 +125,7 @@ export interface Document {

export type FieldArrayProps<
T extends FieldValues,
TFieldArrayName extends FieldArrayPath<T> = FieldArrayPath<T>
TFieldArrayName extends FieldArrayPath<T> = FieldArrayPath<T>,
> = {
control: Control<T, unknown>;
name: TFieldArrayName;
Expand All @@ -108,7 +136,7 @@ export type FieldArrayProps<

export type FieldGroupProps<
T extends FieldValues,
TFieldArrayName extends FieldArrayPath<T> = FieldArrayPath<T>
TFieldArrayName extends FieldArrayPath<T> = FieldArrayPath<T>,
> = {
control: Control<T, unknown>;
name: TFieldArrayName;
Expand Down
Loading

0 comments on commit 125c4fa

Please sign in to comment.