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

feat: GEO-888 Schedule to email about expiring announcements #702

Merged
merged 17 commits into from
Aug 30, 2024
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: 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);
});
});
});
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