Skip to content

Commit

Permalink
feat: assignment email notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
RRanath committed Apr 23, 2024
1 parent 6875d02 commit d87760b
Show file tree
Hide file tree
Showing 30 changed files with 993 additions and 37 deletions.
5 changes: 3 additions & 2 deletions app/backend/lib/ches/sendEmailMerge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import config from '../../../config';

const CHES_API_URL = config.get('CHES_API_URL');

interface Context {
export interface Context {
to: string[];
cc: string[];
context: { [key: string]: any };
delayTS: number;
tag: string;
Expand Down Expand Up @@ -41,7 +42,7 @@ const sendEmailMerge = async (
throw new Error(`Error sending merge with status: ${response.status}`);
}
const sendEmailResult = await response.json();
return sendEmailResult.messages[0].msgId;
return sendEmailResult;
} catch (error: any) {
Sentry.captureException(new Error(error.message));
throw new Error(error.message);
Expand Down
12 changes: 12 additions & 0 deletions app/backend/lib/emails/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import RateLimit from 'express-rate-limit';
import agreementSignedStatusChange from './templates/agreementSignedStatusChange';
import assesmentSecondReviewChange from './templates/assesmentSecondReviewChange';
import handleEmailNotification from './handleEmailNotification';
import assessmentAssigneeChange from './templates/assessmentAssigneeChange';

const email = Router();

Expand All @@ -23,4 +24,15 @@ email.post('/api/email/notifySecondReviewRequest', limiter, (req, res) => {
});
});

email.post('/api/email/assessmentAssigneeChange', limiter, (req, res) => {
const { params } = req.body;
return handleEmailNotification(
req,
res,
assessmentAssigneeChange,
params,
true
);
});

export default email;
95 changes: 74 additions & 21 deletions app/backend/lib/emails/handleEmailNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import getAccessToken from '../ches/getAccessToken';
import { performQuery } from '../graphql';
import config from '../../../config';
import sendEmail from '../ches/sendEmail';
import sendEmailMerge, { Context } from '../ches/sendEmailMerge';

const getAnalystEmailsByIds = `
query getAnalystEmailsByIds($_rowIds: [Int!]!) {
Expand All @@ -24,15 +25,18 @@ export interface EmailTemplate {
emailCC: number[];
subject: any;
body: any;
contexts?: Context[];
params?: any;
}

export interface EmailTemplateProvider {
(
applicationId: string,
host: string,
eventInitiator?: any,
params?: any
): EmailTemplate;
params?: any,
req?: any
): EmailTemplate | Promise<EmailTemplate>;
}

const isAuthorized = (authRole: any) => {
Expand All @@ -44,10 +48,17 @@ const isAuthorized = (authRole: any) => {
* Temporary function to get email recipients from the database based on hardcoded IDs
* To be replaced with proper email notification system pulls from DB
*/
export const getEmailRecipients = async (ids: number[], req: any) => {
export const getEmailRecipients = async (
req: any,
ids?: number[],
emails?: string[]
) => {
const publicRuntimeConfig = getConfig()?.publicRuntimeConfig;
const namespace = publicRuntimeConfig?.OPENSHIFT_APP_NAMESPACE;
const isProd = namespace?.endsWith('-prod');
if (emails && emails.length > 0) {
return isProd ? emails : [config.get('CHES_TO_EMAIL')];
}
if (!isProd || !ids || ids.length === 0) {
return [config.get('CHES_TO_EMAIL')];
}
Expand All @@ -60,29 +71,42 @@ export const getEmailRecipients = async (ids: number[], req: any) => {
return results?.data?.allAnalysts?.edges.map((edge) => edge.node.email);
};

const handleEmailNotification = async (
req,
res,
template: EmailTemplateProvider,
params: any = {}
const handleEmailBatch = async (
res: any,
subject: any,
body: any,
tag: string,
contexts: any[],
details: any
) => {
if (!isAuthorized(getAuthRole(req))) {
return res.status(404).end();
try {
const token = await getAccessToken();
const emailResult = await sendEmailMerge(token, body, subject, contexts);
if (emailResult) {
return res
.status(200)
.json({ emailResult, subject, body, contexts, details })
.end();
}
return res.status(400).json({ error: 'Failed to send email' }).end();
} catch (error) {
return res.status(500).json({ error: 'Internal server error' }).end();
}
const eventInitiator = getAuthUser(req);

const { applicationId, host } = req.body;
const { emailTo, emailCC, tag, subject, body } = template(
applicationId,
host,
eventInitiator,
params
);
};

const sendEmailSingle = async (
emailTo: number[],
emailCC: number[],
req: any,
res: any,
subject: any,
body: any,
tag: string
) => {
try {
const token = await getAccessToken();
const emailToList = await getEmailRecipients(emailTo, req);
const emailCCList = await getEmailRecipients(emailCC, req);
const emailToList = await getEmailRecipients(req, emailTo);
const emailCCList = await getEmailRecipients(req, emailCC);
const emailResult = await sendEmail(
token,
body,
Expand All @@ -100,4 +124,33 @@ const handleEmailNotification = async (
}
};

const handleEmailNotification = async (
req,
res,
template: EmailTemplateProvider,
params: any = {},
isMailMerge = false
) => {
if (!isAuthorized(getAuthRole(req))) {
return res.status(404).end();
}
const eventInitiator = getAuthUser(req);

const { applicationId, host } = req.body;
const {
emailTo,
emailCC,
tag,
subject,
body,
contexts,
params: details,
} = await template(applicationId, host, eventInitiator, params, req);

if (isMailMerge) {
return handleEmailBatch(res, subject, body, tag, contexts, details);
}
return sendEmailSingle(emailTo, emailCC, req, res, subject, body, tag);
};

export default handleEmailNotification;
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ const assesmentSecondReviewChange: EmailTemplateProvider = (
emailTo: [34, 71], // Temporary IDs to handle email recipients
emailCC: [],
tag: 'assesment-second-review-change',
subject: `${initiator} has requested a 2nd Review for ${type} - ${ccbcNumber}`,
subject: `${initiator.givenName} has requested a 2nd Review for ${type} - ${ccbcNumber}`,
body: `
<h1>${initiator} requested a 2nd Review on ${type} - ${ccbcNumber}</h1>
<p>${initiator} requested a 2nd Review on ${type} - ${ccbcNumber}, <a href='${url}/analyst/application/${applicationId}/assessments/${slug}'>Click here</a> to view the ${type} in the CCBC Portal<p>
<h1>${initiator.givenName} requested a 2nd Review on ${type} - ${ccbcNumber}</h1>
<p>${initiator.givenName} requested a 2nd Review on ${type} - ${ccbcNumber}, <a href='${url}/analyst/application/${applicationId}/assessments/${slug}'>Click here</a> to view the ${type} in the CCBC Portal<p>
<p>To unsubscribe from this notification please forward this email with your request to <a href="mailto:[email protected]">[email protected]<a/></p>
`,
};
Expand Down
128 changes: 128 additions & 0 deletions app/backend/lib/emails/templates/assessmentAssigneeChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Context } from 'backend/lib/ches/sendEmailMerge';
import {
EmailTemplate,
EmailTemplateProvider,
getEmailRecipients,
} from '../handleEmailNotification';
import { performQuery } from '../../graphql';

const getCCBCUsersByIds = `
query getCCBCUsersByIds($_rowIds: [Int!]!) {
allCcbcUsers(filter: {rowId: {in: $_rowIds}}) {
edges {
node {
rowId
givenName
}
}
}
}
`;

const getUsers = async (ids: number[], req: any) => {
const results = await performQuery(getCCBCUsersByIds, { _rowIds: ids }, req);
return results?.data?.allCcbcUsers?.edges.reduce((acc, user) => {
acc[user.node.rowId] = user.node.givenName;
return acc;
}, {});
};

const getGroupedContentList = (assessments: any[], property: string) => {
const grouped = {};
return assessments.reduce((result, item) => {
const key = item[property];
if (!result[key]) {
grouped[key] = [];
}
grouped[key].push(item);
return grouped;
}, {});
};

const assessmentAssigneeChange: EmailTemplateProvider = async (
applicationId: string,
url: string,
initiator: string,
params: any,
req?: any
): Promise<EmailTemplate> => {
const assessmentsGrouped = getGroupedContentList(
params.assignments,
'assigneeEmail'
);

/**
* Group the notification content for each assignee
* So we do not send multiple emails but grouped notifications
*/
const request = Object.entries(assessmentsGrouped).map(
async ([assignee, assessments]) => {
const emailTo = await getEmailRecipients(req, [], [assignee]);
const contentList = getGroupedContentList(
assessments as Array<any>,
'updatedBy'
);

const ccbcUserList = await getUsers(
Object.keys(contentList).map((key) => Number(key)),
req
);

/**
* Group each list of notifications by the assignor
*/
const actions = Object.entries(contentList).map(
([assignor, assignments]) => {
const alerts = (assignments as Array<any>).map((assignment) => {
return {
url: `${url}/analyst/application/${assignment.applicationId}/assessments/${assignment.assessmentType}`,
type: assignment.assessmentType,
ccbcNumber: assignment.ccbcNumber,
};
});
return {
assignors: ccbcUserList[assignor],
alerts,
};
}
);
const assignorList = Object.keys(contentList).map(
(key) => ccbcUserList[key]
);

return {
to: emailTo,
cc: [],
context: {
assignee,
assignorList,
actions,
},
delayTS: 0,
tag: 'assignment-assignee-change',
} as Context;
}
);

const contexts = await Promise.all(request);

return {
emailTo: [],
emailCC: [],
tag: 'assignment-assignee-change',
subject: `{% if assignorList.length > 1 %}
{{ assignorList | slice(2) | join(", ") }} and others has(/have) assigned you one or more assessment(s)
{% else %} {{ assignorList | join(" ") }} has assigned you one or more assessment(s)
{% endif %}`,
body: `{% for action in actions %}
{{ action.assignors }} has assigned you the following assessment(s):
<ul>{% for alert in action.alerts %}
<li><a href='{{ alert.url }}'>{{ alert.type | capitalize }}</a> for {{ alert.ccbcNumber }}</li>
{% endfor %}</ul>
{% endfor %}`,
contexts,
params: { assessmentsGrouped },
};
};

export default assessmentAssigneeChange;
1 change: 1 addition & 0 deletions app/components/Analyst/Assessments/AssessmentsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const AssessmentsForm: React.FC<Props> = ({
_jsonData: e.formData,
_assessmentType: slug,
},
connections: [],
},
onCompleted: () => {
setIsFormSaved(true);
Expand Down
Loading

0 comments on commit d87760b

Please sign in to comment.