Skip to content

Commit

Permalink
feat: GEO-888 Schedule to email about expiring announcements (#702)
Browse files Browse the repository at this point in the history
  • Loading branch information
jer3k authored Aug 30, 2024
1 parent 8c7482e commit ef2f9aa
Show file tree
Hide file tree
Showing 23 changed files with 487 additions and 117 deletions.
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"eslint.enable": false,
"eslint.enable": true,
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
},
"[sql]": {
"editor.defaultFormatter": "inferrinizzard.prettier-sql-vscode"
}
},
"prettier.requireConfig": true
}
2 changes: 0 additions & 2 deletions admin-frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ export interface IConfigValue {}
export type User = {
id: string;
displayName: string;
roles: string[];
effectiveRole: string;
};

Expand All @@ -19,4 +18,3 @@ export type UserInvite = {
email: string;
role: string;
};

1 change: 1 addition & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"version": "1.0.0",
"main": "src/server.ts",
"dependencies": {
"@azure/msal-node": "^2.12.0",
"@aws-sdk/client-s3": "^3.631.0",
"@azure/msal-node": "^2.12.0",
"@js-joda/core": "^5.6.1",
"@js-joda/locale_en": "^4.8.11",
"@js-joda/timezone": "^2.21.1",
"@prisma/client": "^5.7.0",
"@prisma/extension-read-replicas": "^0.4.0",
"@types/express-form-data": "^2.0.5",
Expand Down
4 changes: 4 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ config.defaults({
uploadFileMaxSizeBytes: parseFloat(process.env.UPLOAD_FILE_MAX_SIZE),
schedulerDeleteDraftCronTime: process.env.DELETE_DRAFT_REPORT_CRON_CRONTIME,
schedulerLockReportCronTime: process.env.LOCK_REPORT_CRON_CRONTIME,
emailExpiringAnnouncementsCronTime:
process.env.EMAIL_EXPIRING_ANNOUNCEMENTS_CRON_CRONTIME,
schedulerTimeZone: process.env.REPORTS_SCHEDULER_CRON_TIMEZONE,
schedulerExpireAnnountmentsCronTime:
process.env.EXPIRE_ANNOUNCEMENTS_CRON_CRONTIME,
enableEmailExpiringAnnouncements:
process.env.ENABLE_EMAIL_EXPIRING_ANNOUNCEMENTS?.toUpperCase() == 'TRUE',
databaseUrl: datasourceUrl,
firstYearWithPrevReportingYearOption: parseInt(
process.env.FIRST_YEAR_WITH_PREV_REPORTING_YEAR_OPTION || '2025',
Expand Down
45 changes: 25 additions & 20 deletions backend/src/external/services/ches/ches-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,25 @@ export class ChesService {
private axios: Axios;
private readonly apiUrl: string;

constructor({ tokenUrl, clientId, clientSecret, apiUrl, clientConnectionInstance=null }) {
constructor({
tokenUrl,
clientId,
clientSecret,
apiUrl,
clientConnectionInstance = null,
}) {
if (!tokenUrl || !clientId || !clientSecret || !apiUrl) {
logger.error('Invalid configuration.', { function: 'constructor' });
throw new Error('ChesService is not configured. Check configuration.');
}
if(!clientConnectionInstance){
if (!clientConnectionInstance) {
this.connection = new ClientConnection({
tokenUrl,
clientId,
clientSecret
clientSecret,
});
this.axios = this.connection.getAxios();
}else{
} else {
this.axios = clientConnectionInstance.getAxios();
}

Expand All @@ -48,29 +54,27 @@ export class ChesService {
try {
const { data, status } = await this.axios.get(`${this.apiUrl}/health`, {
headers: {
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
});
return { data, status };
} catch (e) {
logger.error(SERVICE, e);
}
}



async send(email: Email) {
try {
const { data, status } = await this.axios.post(
`${this.apiUrl}/email`,
email,
{
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
},
maxContentLength: Infinity,
maxBodyLength: Infinity
}
maxBodyLength: Infinity,
},
);
return { data, status };
} catch (e) {
Expand All @@ -82,9 +86,9 @@ export class ChesService {
/**
* Send an email with retries
* @param email
* @param retries , optional , default value 3
* @param retries , optional , default value 5
*/
async sendEmailWithRetry(email: Email, retries?: number):Promise<string> {
async sendEmailWithRetry(email: Email, retries?: number): Promise<string> {
const retryCount = retries || 5;
try {
await retry(
Expand All @@ -95,7 +99,7 @@ export class ChesService {
if (status === 201) {
logger.info(
SERVICE,
`Email sent successfully , transactionId : ${data.txId}`
`Email sent successfully , transactionId : ${data.txId}`,
);
return data.txId;
} else {
Expand All @@ -106,8 +110,8 @@ export class ChesService {
}
},
{
retries: retryCount
}
retries: retryCount,
},
);
} catch (e) {
logger.error(SERVICE, e);
Expand All @@ -116,19 +120,20 @@ export class ChesService {
}

/**
* Generate an email object with HTML content
* Generate an email object with HTML content. Must provide emailHTMLContent, or, title and body.
* @param subjectLine SUBJECT of email
* @param to array of email addresses
* @param title TITLE of email
* @param body BODY of email
* @param emailHTMLContent HTML content of email
* @param emailHTMLContent (Optional) HTML content of email.
* If provided, title and body is ignored. Otherwise will use title and body to create HTML content.
*/
generateHtmlEmail(
subjectLine: string,
to: string[],
title: string,
body: string,
emailHTMLContent?: string
emailHTMLContent?: string,
): Email {
const emailContents =
emailHTMLContent ||
Expand All @@ -149,7 +154,7 @@ export class ChesService {
from: '[email protected]',
priority: 'normal',
subject: subjectLine,
to: to
to: to,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import waitFor from 'wait-for-expect';
import emailExpiringAnnouncementsJob from './email-expiring-announcements-scheduler';

jest.mock('../v1/services/utils-service', () => ({
utils: {
delay: jest.fn(),
},
}));

const mock_sendAnnouncementExpiringEmails = jest.fn();
jest.mock('../v1/services/scheduler-service', () => ({
schedulerService: {
sendAnnouncementExpiringEmails: () => mock_sendAnnouncementExpiringEmails(),
},
}));

const mock_asyncRetry = jest.fn((fn) => fn());
jest.mock('async-retry', () => ({
__esModule: true,
default: async (fn) => mock_asyncRetry(fn),
}));

const mock_generateHtmlEmail = jest.fn();
const mock_sendEmailWithRetry = jest.fn();

jest.mock('../external/services/ches', () => ({
__esModule: true,
default: {
generateHtmlEmail: (...args) => mock_generateHtmlEmail(...args),
sendEmailWithRetry: (...args) => mock_sendEmailWithRetry(...args),
},
}));

jest.mock('cron', () => ({
CronJob: class MockCron {
constructor(
public expression: string,
public cb,
) {}
async start() {
return this.cb();
}
},
}));

jest.mock('../config', () => ({
config: {
get: (key: string) => {
const settings = {
'server:emailExpiringAnnouncementsCronTime': '121212121',
'server:schedulerTimeZone': 'America/Vancouver',
'ches:enabled': true,
};

return settings[key];
},
},
}));

const mock_tryLock = jest.fn();
const mock_unlock = jest.fn();
jest.mock('advisory-lock', () => ({
...jest.requireActual('advisory-lock'),
default: () => {
return () => ({
tryLock: () => mock_tryLock(),
});
},
}));

describe('email-expiring-announcements-scheduler', () => {
it("should run the 'send emails' function", async () => {
mock_tryLock.mockReturnValue(mock_unlock);
emailExpiringAnnouncementsJob.start();
await waitFor(async () => {
expect(mock_tryLock).toHaveBeenCalledTimes(1);
expect(mock_sendAnnouncementExpiringEmails).toHaveBeenCalled();
expect(mock_unlock).toHaveBeenCalledTimes(1);
});
});
});
25 changes: 25 additions & 0 deletions backend/src/schedulers/email-expiring-announcements-scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { config } from '../config';
import { schedulerService } from '../v1/services/scheduler-service';
import { logger as log } from '../logger';
import advisoryLock from 'advisory-lock';
import { createJob } from './create-job';

const SCHEDULER_NAME = 'EmailExpiringAnnouncements';
const mutex = advisoryLock(config.get('server:databaseUrl'))(
`${SCHEDULER_NAME}-lock`,
);
const crontime = config.get('server:emailExpiringAnnouncementsCronTime');

export default createJob(
crontime,
async () => {
log.info(`Starting scheduled job '${SCHEDULER_NAME}'.`);
await schedulerService.sendAnnouncementExpiringEmails();
log.info(`Completed scheduled job '${SCHEDULER_NAME}'.`);
},
mutex,
{
title: `Error in ${SCHEDULER_NAME}`,
message: `Error in scheduled job: ${SCHEDULER_NAME}`,
},
);
10 changes: 10 additions & 0 deletions backend/src/schedulers/run.all.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,22 @@ jest.mock('./expire-announcements-scheduler', () => ({
},
}));

const mockEmailExpiringAnnouncementsScheduler = jest.fn();
jest.mock('./email-expiring-announcements-scheduler', () => ({
__esModule: true,

default: {
start: () => mockEmailExpiringAnnouncementsScheduler(),
},
}));

describe('run.all', () => {
it('should start all jobs', async () => {
run();
expect(mockDeleteDraftReportLock).toHaveBeenCalled();
expect(mockDeleteUserErrorsLock).toHaveBeenCalled();
expect(mockStartReportLock).toHaveBeenCalled();
expect(mockExpireAnnouncementsLock).toHaveBeenCalled();
expect(mockEmailExpiringAnnouncementsScheduler).toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions backend/src/schedulers/run.all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import deleteDraftReportsJob from './delete-draft-service-scheduler';
import deleteUserErrorsJob from './delete-user-errors-scheduler';
import lockReportsJob from './lock-reports-scheduler';
import expireAnnouncementsJob from './expire-announcements-scheduler';
import emailExpiringAnnouncementsJob from './email-expiring-announcements-scheduler';

export const run = () => {
try {
deleteDraftReportsJob?.start();
deleteUserErrorsJob?.start();
lockReportsJob?.start();
expireAnnouncementsJob?.start();
emailExpiringAnnouncementsJob?.start();
} catch (error) {
/* istanbul ignore next */
logger.error(error);
Expand Down
12 changes: 7 additions & 5 deletions backend/src/v1/routes/admin-users-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import router from './admin-users-routes';
import bodyParser from 'body-parser';
import { faker } from '@faker-js/faker';

const mockGetUsers = jest.fn();
const mockGetUsersForDisplay = jest.fn();
const mockInitSSO = jest.fn();
jest.mock('../services/sso-service', () => ({
SSO: {
Expand Down Expand Up @@ -66,9 +66,11 @@ describe('admin-users-router', () => {
describe('css sso middleware passes', () => {
describe('/ [GET] - get users', () => {
it('400 - if getUsers fails', () => {
mockGetUsers.mockRejectedValue(new Error('Failed to get users'));
mockGetUsersForDisplay.mockRejectedValue(
new Error('Failed to get users'),
);
mockInitSSO.mockReturnValue({
getUsers: () => mockGetUsers(),
getUsersForDisplay: () => mockGetUsersForDisplay(),
});
return request(app)
.get('')
Expand All @@ -78,9 +80,9 @@ describe('admin-users-router', () => {
});
});
it('200 - return a list of users', () => {
mockGetUsers.mockResolvedValue([]);
mockGetUsersForDisplay.mockResolvedValue([]);
mockInitSSO.mockReturnValue({
getUsers: () => mockGetUsers(),
getUsersForDisplay: () => mockGetUsersForDisplay(),
});
return request(app)
.get('')
Expand Down
2 changes: 1 addition & 1 deletion backend/src/v1/routes/admin-users-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ router.use(async (req: SsoRequest, _, next) => {
*/
router.get('', async (req: SsoRequest, res: Response) => {
try {
const users = await req.sso.getUsers();
const users = await req.sso.getUsersForDisplay();
return res.status(200).json(users);
} catch (error) {
logger.error(error);
Expand Down
Loading

0 comments on commit ef2f9aa

Please sign in to comment.