Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add exit code on error #3638

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
2 changes: 2 additions & 0 deletions app/backend/lib/emails/handleEmailNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
'ccbc_auth_user',
'super_admin',
'cbc_admin',
'ccbc_service_account',
];
return authRole && authorizedRoles.includes(authRole.pgRole);
};
Expand Down Expand Up @@ -89,6 +90,7 @@
try {
const token = await getAccessToken();
const emailResult = await sendEmailMerge(token, body, subject, contexts);
console.log(emailResult, contexts, details);

Check warning

Code scanning / ESLint

Disallow the use of `console` Warning

Unexpected console statement.
if (emailResult) {
const emailRecordResults = emailResult.messages.map(
(message: any, i: number) => {
Expand Down
78 changes: 78 additions & 0 deletions app/backend/lib/emails/templates/notifyMilestoneReportDue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { performQuery } from '../../graphql';
import { Context } from '../../ches/sendEmailMerge';
import {
EmailTemplate,
EmailTemplateProvider,
replaceEmailsInNonProd,
} from '../handleEmailNotification';

const getAnalystInfoByUserIds = `
query getAnalystNameByUserIds($_rowIds: [Int!]!) {
allAnalysts(filter: {rowId: {in: $_rowIds}}) {
nodes {
email
givenName
}
}
}
`;
const getEmails = async (ids: number[], req: any) => {
const results = await performQuery(
getAnalystInfoByUserIds,
{ _rowIds: ids },
req
);
return results?.data?.allAnalysts.nodes;
};

const notifyMilestoneReportDue: EmailTemplateProvider = async (
applicationId: string,
url: string,
initiator: any,
params: any,
req
): Promise<EmailTemplate> => {
const { milestoneReportData } = params;
const recipients = [70, 71];

const emails = await getEmails(recipients, req);

const contexts = emails.map((email) => {
const { givenName, email: recipientEmail } = email;
const emailTo = replaceEmailsInNonProd([recipientEmail]);
const emailCC = replaceEmailsInNonProd([]);
return {
to: emailTo,
cc: emailCC,
context: {
recipientName: givenName,
milestones: milestoneReportData,
},
delayTS: 0,
tag: 'milestone-due',
} as Context;
});

const subject = `Reminder: Milestone Report${milestoneReportData.length > 1 ? 's' : ''} ${milestoneReportData.length > 1 ? 'are' : 'is'} coming Due`;

return {
emailTo: [],
emailCC: [],
tag: 'milestone-due',
subject,
body: `
<p>Hi {{ recipientName | trim }} </p>
<p>This is a notification to let you know that one or more Milestone Reports are coming due in 30 days: <p>
<ul>
{% for milestone in milestones %}
<li>Milestone {{ milestone.milestoneNumber }} for {{ milestone.organizationName | trim }}. Project: {{ milestone.ccbcNumber }}. Due {{ milestone.milestoneDate }}</li>
{% endfor %}
</ul>
<p>To unsubscribe from these email notifications, email [email protected]</p>
`,
contexts,
params: { milestoneReportData },
};
};

export default notifyMilestoneReportDue;
175 changes: 175 additions & 0 deletions app/backend/lib/milestoneDueDate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Router } from 'express';
import RateLimit from 'express-rate-limit';
import getAuthRole from '../../utils/getAuthRole';
import { performQuery } from './graphql';
import handleEmailNotification from './emails/handleEmailNotification';
import notifyMilestoneReportDue from './emails/templates/notifyMilestoneReportDue';
import validateKeycloakToken from './keycloakValidate';

const limiter = RateLimit({
windowMs: 1 * 60 * 1000,
max: 30,
});

const milestonesRouter = Router();

const processMilestones = async (req, res) => {
// GraphQL query to get all milestones with archivedAt: null
const sowMilestoneQuery = `
query MilestoneDatesQuery {
allApplicationSowData(
orderBy: AMENDMENT_NUMBER_DESC
filter: {archivedAt: {isNull: true}}
) {
nodes {
applicationId
applicationByApplicationId {
ccbcNumber
organizationName
projectName
}
sowTab2SBySowId(
first: 1
orderBy: ID_DESC
filter: {archivedAt: {isNull: true}}
) {
nodes {
jsonData
}
}
}
}
}
`;
let result;
const applicationRowIdsVisited = new Set();

try {
result = await performQuery(sowMilestoneQuery, {}, req);
} catch (error) {
return res.status(500).json({ error: result.error }).end();
}

const today = new Date();
// Function to check if a given due date string is within 30 to 31 days from today.
const isWithin30To31Days = (dueDateStr) => {
const dueDate = new Date(dueDateStr);
const timeDiff = dueDate.getTime() - today.getTime();
const daysDiff = timeDiff / (1000 * 3600 * 24);
return daysDiff >= 30 && daysDiff <= 31;
};

// Traverse the result, if there is a milestone due date within 30 to 31 days from today,
// add the application row ID, CCBC number, and whether it is a milestone 1 or 2 to a list.
const milestoneReportData = result.data.allApplicationSowData.nodes.reduce(
(acc, node) => {
const { applicationId, applicationByApplicationId, sowTab2SBySowId } =
node;
if (applicationRowIdsVisited.has(applicationId)) {
return acc;
}
const { ccbcNumber, organizationName, projectName } =
applicationByApplicationId;
const milestoneData: Array<any> = sowTab2SBySowId.nodes[0]
?.jsonData as Array<any>;
const milestoneOneDue = milestoneData.find((milestone) =>
isWithin30To31Days(milestone.milestone1)
);
const milestoneTwoDue = milestoneData.find((milestone) =>
isWithin30To31Days(milestone.milestone2)
);
const milestoneThreeDue = milestoneData.find((milestone) =>
isWithin30To31Days(milestone.milestone3)
);
if (milestoneOneDue) {
const applicationRowId = applicationId;
if (!applicationRowIdsVisited.has(applicationRowId)) {
acc.push({
applicationRowId,
ccbcNumber,
organizationName,
projectName,
milestoneNumber: '1',
milestoneDate: new Date(
milestoneOneDue.milestone1
).toLocaleDateString(),
});
applicationRowIdsVisited.add(applicationRowId);
}
}
if (milestoneTwoDue) {
const applicationRowId = applicationId;
if (!applicationRowIdsVisited.has(applicationRowId)) {
acc.push({
applicationRowId,
ccbcNumber,
organizationName,
projectName,
milestoneNumber: '2',
milestoneDate: new Date(
milestoneTwoDue.milestone2
).toLocaleDateString(),
});
applicationRowIdsVisited.add(applicationRowId);
}
}
if (milestoneThreeDue) {
const applicationRowId = applicationId;
if (!applicationRowIdsVisited.has(applicationRowId)) {
acc.push({
applicationRowId,
ccbcNumber,
organizationName,
projectName,
milestoneNumber: '3',
milestoneDate: new Date(
milestoneThreeDue.milestone3
).toLocaleDateString(),
});
applicationRowIdsVisited.add(applicationRowId);
}
}
return acc;
},
[]
);

if (milestoneReportData.length > 0) {
// Send an email to the analyst with the list of applications that have milestones due within 30 to 31 days.
return handleEmailNotification(
req,
res,
notifyMilestoneReportDue,
{ milestoneReportData },
true
);
}

return res
.status(200)
.json({ message: 'No milestones due in 30 days' })
.end();
};

milestonesRouter.get('/api/analyst/milestone/upcoming', limiter, (req, res) => {
const authRole = getAuthRole(req);
const isRoleAuthorized =
authRole?.pgRole === 'ccbc_admin' || authRole?.pgRole === 'super_admin';

if (!isRoleAuthorized) {
return res.status(404).end();
}
return processMilestones(req, res);
});

milestonesRouter.get(
'/api/analyst/cron-milestones',
limiter,
validateKeycloakToken,
(req, res) => {
req.claims.identity_provider = 'serviceaccount';
processMilestones(req, res);
}
);

export default milestonesRouter;
2 changes: 2 additions & 0 deletions app/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import sharepoint from './backend/lib/sharepoint';
import templateUpload from './backend/lib/template-upload';
import s3upload from './backend/lib/s3upload';
import templateNine from './backend/lib/excel_import/template_nine';
import milestoneDue from './backend/lib/milestoneDueDate';

// Function to exclude middleware from certain routes
// The paths argument takes an array of strings containing routes to exclude from the middleware
Expand Down Expand Up @@ -151,6 +152,7 @@ app.prepare().then(async () => {
server.use('/', reporting);
server.use('/', templateNine);
server.use('/', validation);
server.use('/', milestoneDue);

server.all('*', async (req, res) => handle(req, res));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { mocked } from 'jest-mock';
import notifyMilestoneReportDue from '../../../../../backend/lib/emails/templates/notifyMilestoneReportDue';
import { performQuery } from '../../../../../backend/lib/graphql';

jest.mock('../../../../../backend/lib/graphql', () => ({
performQuery: jest.fn(),
}));

jest.mock('../../../../../backend/lib/emails/handleEmailNotification', () => ({
replaceEmailsInNonProd: jest.fn((emails) => emails),
}));

describe('notifyMilestoneReportDue template', () => {
it('should return an email template with correct properties', async () => {
const req = {
body: { applicationId: '1', host: 'http://mock_host.ca' },
};

// Mock the performQuery function to return analyst data
mocked(performQuery).mockResolvedValue({
data: {
allAnalysts: {
nodes: [
{
email: '[email protected]',
givenName: 'Analyst One',
},
{
email: '[email protected]',
givenName: 'Analyst Two',
},
],
},
},
});

const applicationId = '1';
const url = 'http://mock_host.ca';
const milestoneReportData = [
{
milestoneNumber: 1,
organizationName: 'Organization A',
ccbcNumber: 'CCBC-000001',
milestoneDate: '2023-11-01',
},
{
milestoneNumber: 2,
organizationName: 'Organization B',
ccbcNumber: 'CCBC-000002',
milestoneDate: '2023-12-01',
},
];

const params = { milestoneReportData };
const emailTemplate = await notifyMilestoneReportDue(
applicationId,
url,
{},
params,
req
);

// Check that the subject is correct
expect(emailTemplate.subject).toEqual(
'Reminder: Milestone Reports are coming Due'
);

// Ensure contexts are created for each analyst
expect(emailTemplate.contexts.length).toBe(2);

// Validate the first context
expect(emailTemplate.contexts[0].to).toEqual(['[email protected]']);
expect(emailTemplate.contexts[0].context.recipientName).toEqual(
'Analyst One'
);
expect(emailTemplate.contexts[0].context.milestones).toEqual(
milestoneReportData
);

// Validate the second context
expect(emailTemplate.contexts[1].to).toEqual(['[email protected]']);
expect(emailTemplate.contexts[1].context.recipientName).toEqual(
'Analyst Two'
);
expect(emailTemplate.contexts[1].context.milestones).toEqual(
milestoneReportData
);

// Check other properties
expect(emailTemplate.tag).toEqual('milestone-due');
expect(emailTemplate.params).toEqual({ milestoneReportData });

// Optionally, you can check the body content if needed
expect(emailTemplate.body).toContain(
'<p>Hi {{ recipientName | trim }} </p>'
);
});
});
Loading
Loading