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

SPR-169 More email notifications #79

Merged
merged 9 commits into from
Jul 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ SSO_AUTH_SERVER_URL=
GC_NOTIFY_API_KEY=
GC_NOTIFY_ADMIN_EMAIL=

CSS_API_TOKEN_URL=
CSS_API_CLIENT_ID=
CSS_API_CLIENT_SECRET=
CSS_API_BASE_URL=

TESTING=
TEST_USERNAME=
TEST_PASSWORD=
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Create a `.env` file in the root of the project. Populate it with values for eac
| SSO_AUTH_SERVER_URL | https://... | Keycloak. Authorization URL. |
| GC_NOTIFY_API_KEY | somesecret | API Key for GC Notify. |
| GC_NOTIFY_ADMIN_EMAIL | bob@gmail.com | Email address for admin mailbox. |
| CSS_API_TOKEN_URL | https://... | URL for getting CSS API Token. |
| CSS_API_CLIENT_ID | my-id-1234 | Client ID for CSS API account. |
| CSS_API_CLIENT_SECRET | somesecret | Secret for CSS API account. |
| CSS_API_BASE_URL | https://... | Base URL for CSS API. Used for API calls. |
| TESTING | true | Disables Keycloak for API testing. |
| TEST_USERNAME | username | Username used for testing. |
| TEST_PASSWORD | password | Password used for testing. |
Expand Down
4 changes: 3 additions & 1 deletion api/constants/GCNotifyTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* @constant
*/
const Templates = {
NewRequestNotification: '36c9ab59-c467-4393-bab8-c334f364d0d0',
NotifyAdminOfUpdate: '36c9ab59-c467-4393-bab8-c334f364d0d0', // When a user updates a record from Incomplete to Submitted, notify an admin
BradyMitch marked this conversation as resolved.
Show resolved Hide resolved
RemindUserOfIncomplete: '602b7c71-31b1-45ee-935a-bf177bda89bc', // When request has been incomplete for more than a week, notify the requestor
NotifyUserOfChange: 'd3ea3626-7182-4577-8f9d-2f277c7fd015', // When an admin changes a record to Incomplete or Complete, notify the requestor
};

export default Templates;
11 changes: 0 additions & 11 deletions api/controllers/chefs-api-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import { Request, Response } from 'express';
import { Collection } from 'mongodb';
import chefRequestSchema from '../schemas/chefRequestSchema';
import RequestStates from '../constants/RequestStates';
import { sendNewRequestNotification } from '../helpers/useGCNotify';
import Constants from '../constants/Constants';

/**
* @interface
Expand Down Expand Up @@ -37,9 +35,6 @@ const removeBlankKeys = (obj: object) => {
* @returns {Response} Response with status code and either text or JSON data
*/
const submitRequestHandler = async (req: ChefsRequest, res: Response) => {
const { GC_NOTIFY_ADMIN_EMAIL } = process.env;
const { TESTING, FRONTEND_URL } = Constants;

let requestData = { ...req.body };
try {
// Remove properties that may be blank. Otherwise the validation does not pass for optional fields.
Expand All @@ -62,12 +57,6 @@ const submitRequestHandler = async (req: ChefsRequest, res: Response) => {
const response = await collection.insertOne(newPurchaseRequest);
// If insertedID exists, the insert was successful!
if (response.insertedId) {
if (!TESTING) {
sendNewRequestNotification(
GC_NOTIFY_ADMIN_EMAIL,
`${FRONTEND_URL}/request/${response.insertedId}`,
);
}
return res.status(201).json({ ...newPurchaseRequest, _id: response.insertedId });
}
// Generic error response
Expand Down
19 changes: 18 additions & 1 deletion api/controllers/requests-api-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { getUserInfo } from '../keycloak/utils';
import Constants from '../constants/Constants';
import { Purchase } from '../interfaces/Purchase';
import { Approval } from '../interfaces/Approval';
import { sendChangeNotification, sendRequestSubmittedNotification } from '../helpers/useGCNotify';
import { getIDIRUser, IDIRUser } from '../helpers/useCssApi';

// All functions use requests collection
const collection: Collection<RequestRecord> = db.collection<RequestRecord>('requests');
Expand Down Expand Up @@ -203,7 +205,8 @@ export const getRequestByID = async (req: Request, res: Response) => {
export const updateRequestState = async (req: Request, res: Response) => {
const { id } = req.params;
const { employeeId, purchases, approvals, additionalComments, state, isAdmin } = req.body;
const { TESTING } = Constants;
const { TESTING, FRONTEND_URL } = Constants;
const { GC_NOTIFY_ADMIN_EMAIL } = process.env;

// If ID doesn't match schema, return a 400
try {
Expand Down Expand Up @@ -239,11 +242,25 @@ export const updateRequestState = async (req: Request, res: Response) => {
// If previous state was Incomplete, change it to Submitted
if (existingRequest.state === RequestStates.INCOMPLETE) {
refinedState = RequestStates.SUBMITTED;
sendRequestSubmittedNotification(
GC_NOTIFY_ADMIN_EMAIL,
`${FRONTEND_URL}/request/${existingRequest._id}`,
);
}
} else {
// Otherwise, submission should be marked as Incomplete
refinedState = RequestStates.INCOMPLETE;
}
} else {
// User is admin
// If the state is changed by admin to Incomplete or Complete, notify requestor
if (refinedState === RequestStates.INCOMPLETE || refinedState === RequestStates.COMPLETE) {
// Get user with matching IDIR
const users: IDIRUser[] = await getIDIRUser(existingRequest.idir);
if (users) {
sendChangeNotification(users.at(0).email, `${FRONTEND_URL}/request/${id}`);
}
}
}

// If new files weren't uploaded, incoming patch won't have base64 file data, only metadata.
Expand Down
51 changes: 51 additions & 0 deletions api/helpers/sendIncompleteReminders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import db from '../db/conn';
import { Collection, WithId } from 'mongodb';
import { RequestRecord } from '../controllers/requests-api-controller';
import RequestStates from '../constants/RequestStates';
import cron from 'node-cron';
import { IDIRUser, getIDIRUser } from './useCssApi';
import { sendIncompleteReminder } from './useGCNotify';
import Constants from '../constants/Constants';

const { FRONTEND_URL } = Constants;

/**
* @description Finds and returns all request records marked as Incomplete.
* @returns {RequestRecord[]} An array of Request Records that have Incomplete status.
*/
const findIncompleteRequests: () => Promise<WithId<RequestRecord>[]> = () => {
const collection: Collection<RequestRecord> = db.collection<RequestRecord>('requests');
return collection
.find({
state: RequestStates.INCOMPLETE,
})
.toArray();
};

/**
* @description Sends reminders to the email of each record.
* @param {RequestRecord[]} requests An array of Request Records.
*/
const sendReminders = (requests: RequestRecord[]) => {
requests.forEach((request) => {
getIDIRUser(request.idir).then((users: IDIRUser[]) => {
if (users) {
sendIncompleteReminder(users.at(0).email, `${FRONTEND_URL}/request/${request._id}`);
}
});
});
};

/**
* @description Schedules the Incomplete mailing task
*/
export const sendIncompleteReminders = () => {
// minute, hour, day of month, month, day of week
const cronSchedule = '* 2 * * 0'; // Runs Sunday at 2am.
cron.schedule(cronSchedule, async () => {
console.log(`Starting Incomplete reminder routine at ${new Date().toISOString()}.`);
const incompleteRequests = await findIncompleteRequests();
sendReminders(incompleteRequests);
console.log(`Incomplete reminder routine complete at ${new Date().toISOString()}.`);
});
};
63 changes: 63 additions & 0 deletions api/helpers/useCssApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Related Resources:
// Wiki: https://github.com/bcgov/sso-keycloak/wiki/CSS-API-Account
// Swagger: https://api.loginproxy.gov.bc.ca/openapi/swagger

import axios from 'axios';
import oauth from 'axios-oauth-client';

const { CSS_API_TOKEN_URL, CSS_API_CLIENT_ID, CSS_API_CLIENT_SECRET, CSS_API_BASE_URL } =
process.env;

/**
* @interface
* @description The contents of an IDIR User sent back from the CSS API.
* @property {string} username The user's IDIR shortform. e.g. JSMITH
* @property {string} firstName The user's first name.
* @property {string} lastName The user's last name.
* @property {string} email The user's email.
* @property {object} attributes A series of string arrays. The values mirror the other fields in this interface.
*/
export interface IDIRUser {
username: string;
firstName: string;
lastName: string;
email: string;
attributes: {
idir_user_guid: [string];
idir_username: [string];
display_name: [string];
};
}

/**
* @description Gets a bearer token from the CSS API needed to make additional calls for project-related information.
*/
const getClientCredentials = oauth.clientCredentials(
axios.create(),
CSS_API_TOKEN_URL as string,
CSS_API_CLIENT_ID as string,
CSS_API_CLIENT_SECRET as string,
);

/**
* @description Retrieves a user's information based on their IDIR.
* @param idir A user's unique identification string.
* @returns {IDIRUser[]} An array of IDIR Users.
*/
export const getIDIRUser = async (idir: string) => {
try {
const auth = await getClientCredentials('');
// => { "access_token": "...", "expires_in": 900, ... }

const response = await axios.get(`${CSS_API_BASE_URL as string}/idir/users?guid=${idir}`, {
headers: {
Authorization: `Bearer ${auth.access_token}`,
'Content-Type': 'application/json',
},
});
return response.data.data; // First data is from axios, second from CSS API
} catch (e) {
console.log(e);
return [];
}
};
62 changes: 39 additions & 23 deletions api/helpers/useGCNotify.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,47 @@
import axios from 'axios';
import Templates from '../constants/GCNotifyTemplates';
import Constants from '../constants/Constants';

/**
* @description Sends an email notifying the recipient of a new request.
* @param {string} email The email address for the recipient mailbox.
* @param {string} link The URL needed to access the new request submission.
*/
export const sendNewRequestNotification = async (email: string, link: string) => {
const contactGCNotify = async (email: string, url: string, template: string) => {
const { GC_NOTIFY_API_KEY } = process.env;
try {
await axios.post(
'https://api.notification.canada.ca/v2/notifications/email',
{
template_id: Templates.NewRequestNotification,
email_address: email,
personalisation: {
url: link,
const { TESTING } = Constants;
if (!TESTING) {
try {
await axios.post(
'https://api.notification.canada.ca/v2/notifications/email',
{
template_id: template,
email_address: email,
personalisation: {
url,
},
},
},
{
headers: {
Authorization: `ApiKey-v1 ${GC_NOTIFY_API_KEY}`,
'Content-Type': 'application/json',
{
headers: {
Authorization: `ApiKey-v1 ${GC_NOTIFY_API_KEY}`,
'Content-Type': 'application/json',
},
},
},
);
} catch (e) {
console.log(e);
);
} catch (e) {
console.log(e);
}
}
};

/**
* @description Sends an email notifying the recipient of a new request.
* @param {string} email The email address for the recipient mailbox.
* @param {string} url The URL needed to access the new request submission.
*/
export const sendRequestSubmittedNotification = (email: string, url: string) => {
contactGCNotify(email, url, Templates.NotifyAdminOfUpdate);
};

export const sendIncompleteReminder = (email: string, url: string) => {
contactGCNotify(email, url, Templates.RemindUserOfIncomplete);
};

export const sendChangeNotification = (email: string, url: string) => {
contactGCNotify(email, url, Templates.NotifyUserOfChange);
};
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
},
"dependencies": {
"axios": "1.4.0",
"axios-oauth-client": "2.0.2",
"body-parser": "1.20.2",
"compression": "1.7.4",
"cookie-parser": "1.4.6",
Expand Down
2 changes: 2 additions & 0 deletions api/server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { mongoCleanupHelper } from './helpers/mongoCleanup';
import Constants from './constants/Constants';
import app from './express';
import { sendIncompleteReminders } from './helpers/sendIncompleteReminders';

const { API_PORT } = Constants;

app.listen(API_PORT, (err?: Error) => {
if (err) console.log(err);
console.info(`Server started on port ${API_PORT}.`);
mongoCleanupHelper();
sendIncompleteReminders();
});
Loading