Skip to content

Commit

Permalink
Merge pull request #3598 from bcgov/NDT-362-notifications-to-analysts…
Browse files Browse the repository at this point in the history
…-when-milestone-reports-are-coming-due

feat: notification to analysts when upcoming milestone is due
  • Loading branch information
ccbc-service-account authored Nov 5, 2024
2 parents 6571be6 + 21e6653 commit f3a34dc
Show file tree
Hide file tree
Showing 14 changed files with 689 additions and 5 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# [1.204.0](https://github.com/bcgov/CONN-CCBC-portal/compare/v1.203.4...v1.204.0) (2024-11-05)

### Features

- milestone cron ([5e1b65f](https://github.com/bcgov/CONN-CCBC-portal/commit/5e1b65fcd21b469c3a231f01c3cbc398cec5391f))
- send emails for milestone when due in 30 days ([793372e](https://github.com/bcgov/CONN-CCBC-portal/commit/793372e7860fa730535c81f0a8843527d3abf5b5))
- service account permissions and get scope in request ([41d84c5](https://github.com/bcgov/CONN-CCBC-portal/commit/41d84c5ec98dea4bc066b6c6bd75fdbb286b8b78))

## [1.203.4](https://github.com/bcgov/CONN-CCBC-portal/compare/v1.203.3...v1.203.4) (2024-11-01)

## [1.203.3](https://github.com/bcgov/CONN-CCBC-portal/compare/v1.203.2...v1.203.3) (2024-10-29)
Expand Down
1 change: 1 addition & 0 deletions app/backend/lib/emails/handleEmailNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const isAuthorized = (authRole: any) => {
'ccbc_auth_user',
'super_admin',
'cbc_admin',
'ccbc_service_account',
];
return authRole && authorizedRoles.includes(authRole.pgRole);
};
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;
176 changes: 176 additions & 0 deletions app/backend/lib/milestoneDueDate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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);
today.setHours(0, 0, 0, 0);
const timeDiff = dueDate.getTime() - today.getTime();
const daysDiff = Math.round(timeDiff / (1000 * 3600 * 24));
return daysDiff === 30;
};

// 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) => {
return isWithin30To31Days(milestone.milestone1);
});
const milestoneTwoDue = milestoneData.find((milestone) => {
return isWithin30To31Days(milestone.milestone2);
});
const milestoneThreeDue = milestoneData.find((milestone) => {
return 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(),
});
}
}
if (milestoneTwoDue) {
const applicationRowId = applicationId;
if (!applicationRowIdsVisited.has(applicationRowId)) {
acc.push({
applicationRowId,
ccbcNumber,
organizationName,
projectName,
milestoneNumber: '2',
milestoneDate: new Date(
milestoneTwoDue.milestone2
).toLocaleDateString(),
});
}
}
if (milestoneThreeDue) {
const applicationRowId = applicationId;
if (!applicationRowIdsVisited.has(applicationRowId)) {
acc.push({
applicationRowId,
ccbcNumber,
organizationName,
projectName,
milestoneNumber: '3',
milestoneDate: new Date(
milestoneThreeDue.milestone3
).toLocaleDateString(),
});
}
}
if (milestoneThreeDue || milestoneOneDue || milestoneTwoDue) {
applicationRowIdsVisited.add(applicationId);
}
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

0 comments on commit f3a34dc

Please sign in to comment.