diff --git a/.vscode/settings.json b/.vscode/settings.json index b043ade4f..050665ac5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 } diff --git a/admin-frontend/src/types/index.ts b/admin-frontend/src/types/index.ts index 05feedfa7..d18c63d0d 100644 --- a/admin-frontend/src/types/index.ts +++ b/admin-frontend/src/types/index.ts @@ -3,7 +3,6 @@ export interface IConfigValue {} export type User = { id: string; displayName: string; - roles: string[]; effectiveRole: string; }; @@ -19,4 +18,3 @@ export type UserInvite = { email: string; role: string; }; - diff --git a/backend/package-lock.json b/backend/package-lock.json index 7c6bf9637..54a99df23 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@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", diff --git a/backend/package.json b/backend/package.json index 9fb8631d2..df493dc8d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 08676190a..400310398 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -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', diff --git a/backend/src/external/services/ches/ches-service.ts b/backend/src/external/services/ches/ches-service.ts index 0d9373691..cada7d71e 100644 --- a/backend/src/external/services/ches/ches-service.ts +++ b/backend/src/external/services/ches/ches-service.ts @@ -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(); } @@ -48,8 +54,8 @@ 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) { @@ -57,8 +63,6 @@ export class ChesService { } } - - async send(email: Email) { try { const { data, status } = await this.axios.post( @@ -66,11 +70,11 @@ export class ChesService { email, { headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, maxContentLength: Infinity, - maxBodyLength: Infinity - } + maxBodyLength: Infinity, + }, ); return { data, status }; } catch (e) { @@ -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 { + async sendEmailWithRetry(email: Email, retries?: number): Promise { const retryCount = retries || 5; try { await retry( @@ -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 { @@ -106,8 +110,8 @@ export class ChesService { } }, { - retries: retryCount - } + retries: retryCount, + }, ); } catch (e) { logger.error(SERVICE, e); @@ -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 || @@ -149,7 +154,7 @@ export class ChesService { from: 'no-reply-paytransparency@gov.bc.ca', priority: 'normal', subject: subjectLine, - to: to + to: to, }; } } diff --git a/backend/src/schedulers/email-expiring-announcements-scheduler.spec.ts b/backend/src/schedulers/email-expiring-announcements-scheduler.spec.ts new file mode 100644 index 000000000..c9ee5437d --- /dev/null +++ b/backend/src/schedulers/email-expiring-announcements-scheduler.spec.ts @@ -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); + }); + }); +}); diff --git a/backend/src/schedulers/email-expiring-announcements-scheduler.ts b/backend/src/schedulers/email-expiring-announcements-scheduler.ts new file mode 100644 index 000000000..e5dd4b995 --- /dev/null +++ b/backend/src/schedulers/email-expiring-announcements-scheduler.ts @@ -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}`, + }, +); diff --git a/backend/src/schedulers/run.all.spec.ts b/backend/src/schedulers/run.all.spec.ts index 811552490..e1165927b 100644 --- a/backend/src/schedulers/run.all.spec.ts +++ b/backend/src/schedulers/run.all.spec.ts @@ -36,6 +36,15 @@ 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(); @@ -43,5 +52,6 @@ describe('run.all', () => { expect(mockDeleteUserErrorsLock).toHaveBeenCalled(); expect(mockStartReportLock).toHaveBeenCalled(); expect(mockExpireAnnouncementsLock).toHaveBeenCalled(); + expect(mockEmailExpiringAnnouncementsScheduler).toHaveBeenCalled(); }); }); diff --git a/backend/src/schedulers/run.all.ts b/backend/src/schedulers/run.all.ts index 207981ed0..753d89cce 100644 --- a/backend/src/schedulers/run.all.ts +++ b/backend/src/schedulers/run.all.ts @@ -3,6 +3,7 @@ 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 { @@ -10,6 +11,7 @@ export const run = () => { deleteUserErrorsJob?.start(); lockReportsJob?.start(); expireAnnouncementsJob?.start(); + emailExpiringAnnouncementsJob?.start(); } catch (error) { /* istanbul ignore next */ logger.error(error); diff --git a/backend/src/v1/routes/admin-users-routes.spec.ts b/backend/src/v1/routes/admin-users-routes.spec.ts index 26f49b857..073381739 100644 --- a/backend/src/v1/routes/admin-users-routes.spec.ts +++ b/backend/src/v1/routes/admin-users-routes.spec.ts @@ -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: { @@ -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('') @@ -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('') diff --git a/backend/src/v1/routes/admin-users-routes.ts b/backend/src/v1/routes/admin-users-routes.ts index 0c2c97538..5ec3504d2 100644 --- a/backend/src/v1/routes/admin-users-routes.ts +++ b/backend/src/v1/routes/admin-users-routes.ts @@ -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); diff --git a/backend/src/v1/services/announcements-service.spec.ts b/backend/src/v1/services/announcements-service.spec.ts index dd1f07a8e..7caed5cd4 100644 --- a/backend/src/v1/services/announcements-service.spec.ts +++ b/backend/src/v1/services/announcements-service.spec.ts @@ -7,6 +7,7 @@ import { import { UserInputError } from '../types/errors'; import * as AnnouncementService from './announcements-service'; import { utils } from './utils-service'; +import { LocalDateTime, ZonedDateTime, ZoneId } from '@js-joda/core'; const mockFindMany = jest.fn().mockResolvedValue([ { @@ -70,6 +71,17 @@ jest.mock('../prisma/prisma-client', () => ({ }, })); +jest.mock('../../config', () => ({ + config: { + get: (key: string) => { + const settings = { + 'server:schedulerTimeZone': 'America/Vancouver', + }; + return settings[key]; + }, + }, +})); + describe('AnnouncementsService', () => { afterEach(() => { jest.clearAllMocks(); @@ -421,7 +433,7 @@ describe('AnnouncementsService', () => { description: faker.lorem.words(10), expires_on: faker.date.recent().toISOString(), published_on: faker.date.future().toISOString(), - status: 'PUBLISHED', + status: AnnouncementStatus.Published, linkDisplayName: faker.lorem.words(3), linkUrl: faker.internet.url(), attachmentId: 'attachment-id', @@ -442,7 +454,7 @@ describe('AnnouncementsService', () => { 'fileDisplayName', ), announcement_status: { - connect: { code: 'PUBLISHED' }, + connect: { code: AnnouncementStatus.Published }, }, announcement_resource: { createMany: { @@ -520,7 +532,7 @@ describe('AnnouncementsService', () => { description: faker.lorem.words(10), expires_on: faker.date.recent().toISOString(), published_on: faker.date.future().toISOString(), - status: 'PUBLISHED', + status: AnnouncementStatus.Published, linkDisplayName: faker.lorem.words(3), linkUrl: faker.internet.url(), }; @@ -549,7 +561,7 @@ describe('AnnouncementsService', () => { published_on: announcementInput.published_on, updated_date: expect.any(Date), announcement_status: { - connect: { code: 'PUBLISHED' }, + connect: { code: AnnouncementStatus.Published }, }, admin_user_announcement_updated_byToadmin_user: { connect: { admin_user_id: 'user-id' }, @@ -571,7 +583,7 @@ describe('AnnouncementsService', () => { description: faker.lorem.words(10), expires_on: faker.date.recent().toISOString(), published_on: faker.date.future().toISOString(), - status: 'PUBLISHED', + status: AnnouncementStatus.Published, }; await AnnouncementService.updateAnnouncement( 'announcement-id', @@ -592,7 +604,7 @@ describe('AnnouncementsService', () => { published_on: announcementInput.published_on, updated_date: expect.any(Date), announcement_status: { - connect: { code: 'PUBLISHED' }, + connect: { code: AnnouncementStatus.Published }, }, admin_user_announcement_updated_byToadmin_user: { connect: { admin_user_id: 'user-id' }, @@ -614,7 +626,7 @@ describe('AnnouncementsService', () => { description: faker.lorem.words(10), expires_on: faker.date.recent().toISOString(), published_on: faker.date.future().toISOString(), - status: 'PUBLISHED', + status: AnnouncementStatus.Published, linkDisplayName: faker.lorem.words(3), linkUrl: faker.internet.url(), }; @@ -654,7 +666,7 @@ describe('AnnouncementsService', () => { published_on: announcementInput.published_on, updated_date: expect.any(Date), announcement_status: { - connect: { code: 'PUBLISHED' }, + connect: { code: AnnouncementStatus.Published }, }, admin_user_announcement_updated_byToadmin_user: { connect: { admin_user_id: 'user-id' }, @@ -681,7 +693,7 @@ describe('AnnouncementsService', () => { description: faker.lorem.words(10), expires_on: faker.date.recent().toISOString(), published_on: faker.date.future().toISOString(), - status: 'PUBLISHED', + status: AnnouncementStatus.Published, attachmentId: attachmentId, fileDisplayName: faker.lorem.words(3), }; @@ -710,7 +722,7 @@ describe('AnnouncementsService', () => { published_on: announcementInput.published_on, updated_date: expect.any(Date), announcement_status: { - connect: { code: 'PUBLISHED' }, + connect: { code: AnnouncementStatus.Published }, }, admin_user_announcement_updated_byToadmin_user: { connect: { admin_user_id: 'user-id' }, @@ -732,7 +744,7 @@ describe('AnnouncementsService', () => { description: faker.lorem.words(10), expires_on: faker.date.recent().toISOString(), published_on: faker.date.future().toISOString(), - status: 'PUBLISHED', + status: AnnouncementStatus.Published, attachmentId: faker.string.uuid(), fileDisplayName: faker.lorem.word(), }; @@ -772,7 +784,7 @@ describe('AnnouncementsService', () => { published_on: announcementInput.published_on, updated_date: expect.any(Date), announcement_status: { - connect: { code: 'PUBLISHED' }, + connect: { code: AnnouncementStatus.Published }, }, admin_user_announcement_updated_byToadmin_user: { connect: { admin_user_id: 'user-id' }, @@ -839,4 +851,28 @@ describe('AnnouncementsService', () => { }); }); }); + + describe('getExpiringAnnouncements', () => { + it('should return only announcements that will expire', async () => { + jest + .spyOn(ZonedDateTime, 'now') + .mockImplementationOnce((zone) => + ZonedDateTime.of( + LocalDateTime.parse('2024-08-26T11:38:23.561'), + zone as ZoneId, + ), + ); + await AnnouncementService.getExpiringAnnouncements(); + + expect(mockFindMany).toHaveBeenCalledWith({ + where: { + expires_on: { + gte: new Date('2024-09-05T07:00:00.000Z'), + lt: new Date('2024-09-06T07:00:00.000Z'), + }, + status: AnnouncementStatus.Published, + }, + }); + }); + }); }); diff --git a/backend/src/v1/services/announcements-service.ts b/backend/src/v1/services/announcements-service.ts index ae9e08696..c7b211e91 100644 --- a/backend/src/v1/services/announcements-service.ts +++ b/backend/src/v1/services/announcements-service.ts @@ -18,6 +18,8 @@ import { } from '../types/announcements'; import { UserInputError } from '../types/errors'; import { utils } from './utils-service'; +import { config } from '../../config'; +import '@js-joda/timezone'; const saveHistory = async ( tx: Omit< @@ -423,7 +425,7 @@ export const expireAnnouncements = async () => { announcement_id: true, }, where: { - status: 'PUBLISHED', + status: AnnouncementStatus.Published, expires_on: { not: null, lte: nowUtc, @@ -446,3 +448,25 @@ export const expireAnnouncements = async () => { } }); }; + +/** Get announcements that are 10 days away from expiring */ +export const getExpiringAnnouncements = async (): Promise => { + const zone = ZoneId.of(config.get('server:schedulerTimeZone')); + const targetDate = ZonedDateTime.now(zone) + .plusDays(10) + .withHour(0) + .withMinute(0) + .withSecond(0) + .withNano(0); + const items = await prisma.announcement.findMany({ + where: { + status: AnnouncementStatus.Published, + expires_on: { + gte: convert(targetDate).toDate(), + lt: convert(targetDate.plusDays(1)).toDate(), + }, + }, + }); + + return items; +}; diff --git a/backend/src/v1/services/scheduler-service.spec.ts b/backend/src/v1/services/scheduler-service.spec.ts index 8cac4162a..17b61b117 100644 --- a/backend/src/v1/services/scheduler-service.spec.ts +++ b/backend/src/v1/services/scheduler-service.spec.ts @@ -2,13 +2,20 @@ import { LocalDate, LocalDateTime, ZoneId, + ZonedDateTime, convert, nativeJs, } from '@js-joda/core'; -import { Prisma, pay_transparency_report } from '@prisma/client'; +import { + Prisma, + admin_user, + announcement, + pay_transparency_report, +} from '@prisma/client'; import prisma from '../prisma/prisma-client'; import { enumReportStatus } from './report-service'; import { schedulerService } from './scheduler-service'; +import { faker } from '@faker-js/faker'; jest.mock('./utils-service'); jest.mock('../prisma/prisma-client', () => { @@ -54,6 +61,54 @@ const mockCalculatedDatasInDB = [ { ...mockDraftReport, report_id: '456769' }, ]; +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), + }, +})); + +const mockGetExpiringAnnouncements = jest.fn(); +jest.mock('./announcements-service', () => ({ + getExpiringAnnouncements: () => mockGetExpiringAnnouncements(), +})); + +const mockGetUsers = jest.fn(); +const mockInitSSO = jest.fn(); +jest.mock('./sso-service', () => ({ + SSO: { + init: () => mockInitSSO(), + }, +})); + +const mockConfigGet = jest.fn(); +jest.mock('../../config', () => ({ + config: { + get: (...args) => mockConfigGet(...args), + }, +})); + +const mockDBAdminUsers: Partial[] = Array(3).fill({ + email: faker.internet.email(), +}); + +const mockDBAnnouncement: Partial[] = [ + { + title: faker.lorem.sentence(), + expires_on: faker.date.soon(), + }, + { + title: 'test title', + expires_on: convert( + ZonedDateTime.parse('2020-05-10T08:50:00.000Z'), + ).toDate(), + }, +]; + afterEach(() => { jest.clearAllMocks(); }); @@ -82,3 +137,60 @@ describe('deleteDraftReports', () => { expect(prisma.pay_transparency_report.deleteMany).toHaveBeenCalledTimes(1); }); }); + +describe('sendAnnouncementExpiringEmails', () => { + beforeEach(async () => { + mockInitSSO.mockResolvedValue({ + getUsers: () => mockGetUsers(), + }); + mockGetUsers.mockResolvedValue(mockDBAdminUsers); + mockGetExpiringAnnouncements.mockResolvedValue(mockDBAnnouncement); + mockConfigGet.mockImplementation((key: string) => { + const settings = { + 'server:openshiftEnv': 'DEV', + 'server:schedulerTimeZone': 'America/Vancouver', + 'ches:enabled': true, + 'server:enableEmailExpiringAnnouncements': true, + }; + return settings[key]; + }); + }); + it('sends emails', async () => { + await schedulerService.sendAnnouncementExpiringEmails(); + expect(mock_sendEmailWithRetry).toHaveBeenCalledTimes(2); //2 expired announcements, 2 emails + // verify details of email + expect(mock_generateHtmlEmail.mock.lastCall[0]).toContain('[DEV]'); + expect(mock_generateHtmlEmail.mock.lastCall[1]).toHaveLength(3); + expect(mock_generateHtmlEmail.mock.lastCall[3]).toContain('test title'); + expect(mock_generateHtmlEmail.mock.lastCall[3]).toContain( + '2020-05-10 at 1:50 a.m.', + ); + }); + it("doesn't do anything if the feature is disabled in the settings", async () => { + mockConfigGet.mockImplementation((key: string) => { + const settings = { + 'server:enableEmailExpiringAnnouncements': false, + }; + + return settings[key]; + }); + await schedulerService.sendAnnouncementExpiringEmails(); + expect(mock_sendEmailWithRetry).toHaveBeenCalledTimes(0); //2 expired announcements, 2 emails + }); + + it('Prod should not contain prefix', async () => { + mockConfigGet.mockImplementation((key: string) => { + const settings = { + 'server:openshiftEnv': 'PROD', + 'server:schedulerTimeZone': 'America/Vancouver', + 'ches:enabled': true, + 'server:enableEmailExpiringAnnouncements': true, + }; + return settings[key]; + }); + await schedulerService.sendAnnouncementExpiringEmails(); + expect(mock_sendEmailWithRetry).toHaveBeenCalledTimes(2); //2 expired announcements, 2 emails + // verify details of email + expect(mock_generateHtmlEmail.mock.lastCall[0]).not.toContain('['); + }); +}); diff --git a/backend/src/v1/services/scheduler-service.ts b/backend/src/v1/services/scheduler-service.ts index 8417a6f6b..d847a217b 100644 --- a/backend/src/v1/services/scheduler-service.ts +++ b/backend/src/v1/services/scheduler-service.ts @@ -1,8 +1,19 @@ -import { LocalDateTime, ZoneId } from '@js-joda/core'; -import { Prisma } from '@prisma/client'; -import { logger } from '../../logger'; +import { + DateTimeFormatter, + LocalDateTime, + nativeJs, + ZoneId, +} from '@js-joda/core'; import prisma from '../prisma/prisma-client'; import { enumReportStatus } from './report-service'; +import { logger } from '../../logger'; +import { Prisma } from '@prisma/client'; +import { getExpiringAnnouncements } from './announcements-service'; +import { SSO } from './sso-service'; +import emailService from '../../external/services/ches'; +import { config } from '../../config'; +import '@js-joda/timezone'; +import { Locale } from '@js-joda/locale_en'; const schedulerService = { /* @@ -34,6 +45,36 @@ const schedulerService = { }); }); }, + + async sendAnnouncementExpiringEmails() { + if (!config.get('server:enableEmailExpiringAnnouncements')) return; + + const sso = await SSO.init(); + const users = await sso.getUsers(); + const emails = users.map((u) => u.email); + const env = config.get('server:openshiftEnv'); + const envPrefix = env === 'PROD' ? '' : `[${env}] `; // If not prod, add environment as a prefix to the subject line + const zone = ZoneId.of(config.get('server:schedulerTimeZone')); + + // Loop through expiring announcements and send emails + const expiring = await getExpiringAnnouncements(); + for (const ann of expiring) { + const expiryStr = nativeJs(ann.expires_on, ZoneId.UTC) + .withZoneSameInstant(zone) + .format( + DateTimeFormatter.ofPattern("uuuu-MM-dd 'at' h:mm a").withLocale( + Locale.CANADA, + ), + ); + const email = emailService.generateHtmlEmail( + `${envPrefix}Pay Transparency | Expiring Announcement`, + emails, + null, + `Please be advised that the announcement titled ${ann.title} will expire on ${expiryStr}`, + ); + await emailService.sendEmailWithRetry(email); + } + }, }; export { schedulerService }; diff --git a/backend/src/v1/services/sso-service.spec.ts b/backend/src/v1/services/sso-service.spec.ts index 9c04b5149..2f4f0cf0b 100644 --- a/backend/src/v1/services/sso-service.spec.ts +++ b/backend/src/v1/services/sso-service.spec.ts @@ -1,5 +1,10 @@ import { faker } from '@faker-js/faker'; import { SSO } from './sso-service'; +import { admin_user } from '@prisma/client'; +import { + PTRT_ADMIN_ROLE_NAME, + PTRT_USER_ROLE_NAME, +} from '../../constants/admin'; const mockAxiosGet = jest.fn(); const mockAxiosPost = jest.fn(); @@ -62,6 +67,21 @@ jest.mock('../services/admin-auth-service', () => { return mocked; }); +const mockDBAdminUsers: Partial[] = [ + { + admin_user_id: faker.internet.userName(), + preferred_username: faker.internet.userName(), + display_name: faker.person.fullName(), + assigned_roles: PTRT_ADMIN_ROLE_NAME + ',' + PTRT_USER_ROLE_NAME, + }, + { + admin_user_id: faker.string.uuid(), + preferred_username: faker.internet.userName(), + display_name: faker.person.fullName(), + assigned_roles: PTRT_USER_ROLE_NAME, + }, +]; + describe('sso-service', () => { beforeEach(() => { jest.clearAllMocks(); @@ -79,30 +99,20 @@ describe('sso-service', () => { }); describe('getUsers', () => { - const preferredUsername1 = faker.internet.userName(); - const preferredUsername2 = faker.internet.userName(); + let client: SSO; - beforeEach(() => { + beforeEach(async () => { mockAxiosPost.mockResolvedValue({ data: { access_token: 'jwt', token_type: 'Bearer' }, }); - mockFindMany.mockResolvedValue([ - { - admin_user_id: faker.string.uuid(), - preferred_username: preferredUsername1, - }, - { - admin_user_id: faker.string.uuid(), - preferred_username: preferredUsername2, - }, - ]); + mockFindMany.mockResolvedValue(mockDBAdminUsers); mockAxiosGet.mockResolvedValue({ data: { data: [ { email: faker.internet.email(), - username: preferredUsername1, + username: mockDBAdminUsers[0].preferred_username, attributes: { idir_user_guid: [faker.string.uuid()], idir_username: [faker.internet.userName()], @@ -111,7 +121,7 @@ describe('sso-service', () => { }, { email: faker.internet.email(), - username: preferredUsername2, + username: mockDBAdminUsers[1].preferred_username, attributes: { idir_user_guid: [faker.string.uuid()], idir_username: [faker.internet.userName()], @@ -121,15 +131,14 @@ describe('sso-service', () => { ], }, }); + client = await SSO.init(); }); - it('should get users for the PTRT-ADMIN and PTRT-USER roles', async () => { + it('should get users for the PTRT-ADMIN and PTRT-USER roles and perform db updates if there is a missmatch', async () => { mockStoreUserInfoWithHistory.mockResolvedValue(true); - const client = await SSO.init(); const users = await client.getUsers(); - expect(mockAxiosGet).toHaveBeenCalledTimes(2); - expect(users.every((u) => u.effectiveRole === 'PTRT-ADMIN')).toBeTruthy(); - expect(users.every((u) => u.roles.length === 2)).toBeTruthy(); - expect(mockFindMany).toHaveBeenCalledTimes(2); + expect(users).toHaveLength(2); + expect(mockAxiosGet).toHaveBeenCalledTimes(2); // called axios once for every role + expect(mockFindMany).toHaveBeenCalledTimes(2); // called once to check if there are any difference, the second time is to get the updates }); it('should throw error if SSO returns no users', async () => { mockAxiosGet.mockResolvedValue({ @@ -137,48 +146,55 @@ describe('sso-service', () => { data: [], }, }); - const client = await SSO.init(); await expect(client.getUsers()).rejects.toThrow(); }); it('should not query the database twice if there were no db updates', async () => { - mockStoreUserInfoWithHistory.mockResolvedValue(false); - const client = await SSO.init(); + mockStoreUserInfoWithHistory.mockResolvedValue(false); // Storing info returns false to signify nothing was changed in the database const users = await client.getUsers(); - expect(mockAxiosGet).toHaveBeenCalledTimes(2); - expect(users.every((u) => u.effectiveRole === 'PTRT-ADMIN')).toBeTruthy(); - expect(users.every((u) => u.roles.length === 2)).toBeTruthy(); - expect(mockFindMany).toHaveBeenCalledTimes(1); + expect(users).toHaveLength(2); + expect(mockFindMany).toHaveBeenCalledTimes(1); // only 1 call at the beginning to get the details, nothing in the database changed, so no second call }); it('should de-activate users in database that have no permissions in sso', async () => { mockFindMany .mockResolvedValueOnce([ + ...mockDBAdminUsers, { - admin_user_id: faker.string.uuid(), - preferred_username: preferredUsername1, - }, - { - admin_user_id: faker.string.uuid(), - preferred_username: preferredUsername2, - }, - { - admin_user_id: faker.string.uuid(), + admin_user_id: faker.string.uuid(), // add a bonus user that should not be there preferred_username: faker.internet.userName(), }, ]) - .mockResolvedValue([ - { - admin_user_id: faker.string.uuid(), - preferred_username: preferredUsername1, - }, - { - admin_user_id: faker.string.uuid(), - preferred_username: preferredUsername2, - }, - ]); + .mockResolvedValue(mockDBAdminUsers); - const client = await SSO.init(); await client.getUsers(); - expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledTimes(1); // one user is different, so update the DB once + }); + }); + + describe('getUsersForDisplay', () => { + let client: SSO; + beforeEach(async () => { + mockAxiosPost.mockResolvedValue({ + data: { access_token: 'jwt', token_type: 'Bearer' }, + }); + client = await SSO.init(); + }); + it('should convert the list of users into an object for the frontend', async () => { + jest + .spyOn(client, 'getUsers') + .mockResolvedValueOnce(mockDBAdminUsers as admin_user[]); + const users = await client.getUsersForDisplay(); + expect(users).toStrictEqual([ + { + displayName: mockDBAdminUsers[0].display_name, + effectiveRole: PTRT_ADMIN_ROLE_NAME, + id: mockDBAdminUsers[0].admin_user_id, + }, + { + displayName: mockDBAdminUsers[1].display_name, + effectiveRole: PTRT_USER_ROLE_NAME, + id: mockDBAdminUsers[1].admin_user_id, + }, + ]); }); }); diff --git a/backend/src/v1/services/sso-service.ts b/backend/src/v1/services/sso-service.ts index 4428572ae..18fcc92ac 100644 --- a/backend/src/v1/services/sso-service.ts +++ b/backend/src/v1/services/sso-service.ts @@ -44,7 +44,6 @@ type GetUserResponse = { type User = { id: string; displayName: string; - roles: string[]; effectiveRole: string; }; type SsoUser = { @@ -85,9 +84,26 @@ export class SSO { return new SSO(client); } - async getUsers(): Promise< - (Omit & { roles: string[]; effectiveRole: string })[] - > { + /** Get simplified user list to pass to frontend */ + async getUsersForDisplay(): Promise { + const users = await this.getUsers(); + + const ret = users.map((u) => ({ + displayName: u.display_name, + id: u.admin_user_id, + effectiveRole: u.assigned_roles + .split(',') + .slice() + .includes(PTRT_ADMIN_ROLE_NAME) + ? PTRT_ADMIN_ROLE_NAME + : PTRT_USER_ROLE_NAME, + })); + + return ret; + } + + /** Get all users from SSO while ensuring DB is up to date. (To ensure our history is accurate) */ + async getUsers(): Promise { // create dictionary of users from SSO const ssoUsers: Record = {}; for (const roleName of ROLE_NAMES) { @@ -172,22 +188,7 @@ export class SSO { }, }); - // merge local database id with sso object - const ret: User[] = Object.values(ssoUsers).map( - (user) => - { - id: localUsers.find( - (x) => x.preferred_username == user.preferredUserName, - ).admin_user_id, - displayName: user.displayName, - effectiveRole: user.roles.includes(PTRT_ADMIN_ROLE_NAME) - ? PTRT_ADMIN_ROLE_NAME - : PTRT_USER_ROLE_NAME, - roles: user.roles, - }, - ); - - return ret; + return localUsers; } /** diff --git a/charts/fin-pay-transparency/templates/configmap.yaml b/charts/fin-pay-transparency/templates/configmap.yaml index 8fafca3dd..77ae01c4f 100644 --- a/charts/fin-pay-transparency/templates/configmap.yaml +++ b/charts/fin-pay-transparency/templates/configmap.yaml @@ -13,6 +13,8 @@ data: DELETE_USER_ERRORS_CRON_CRONTIME: {{ .Values.global.config.delete_user_errors_cron_crontime | quote }} LOCK_REPORT_CRON_CRONTIME: {{ .Values.global.config.lock_report_cron_crontime | quote }} EXPIRE_ANNOUNCEMENTS_CRON_CRONTIME: {{ .Values.global.config.expire_announcements_cron_crontime | quote }} + EMAIL_EXPIRING_ANNOUNCEMENTS_CRON_CRONTIME: {{ .Values.global.config.email_expiring_announcements_cron_crontime | quote }} + ENABLE_EMAIL_EXPIRING_ANNOUNCEMENTS: {{ .Values.global.config.enable_email_expiring_announcements | quote }} REPORTS_SCHEDULER_CRON_TIMEZONE: {{ .Values.global.config.reports_scheduler_cron_timezone | quote }} FIRST_YEAR_WITH_PREV_REPORTING_YEAR_OPTION: {{ .Values.global.config.first_year_with_prev_reporting_year_option | quote }} ADMIN_INVITATION_DURATION_IN_HOURS: {{ .Values.global.config.admin_invitation_duration_in_hours | quote }} diff --git a/charts/fin-pay-transparency/values-dev.yaml b/charts/fin-pay-transparency/values-dev.yaml index 4f0284854..2a4a35ae5 100644 --- a/charts/fin-pay-transparency/values-dev.yaml +++ b/charts/fin-pay-transparency/values-dev.yaml @@ -47,6 +47,8 @@ global: delete_user_errors_cron_crontime: "30 0 1 */6 *" #At 12:30 AM on the 1st day of every 6th month lock_report_cron_crontime: "0 15 0 * * *" # 12:15 AM PST/PDT expire_announcements_cron_crontime: "* 6,18 * * *" # 6am & 6pm daily + email_expiring_announcements_cron_crontime: "0 7 0 * * *" # 7:00 AM PST/PDT + enable_email_expiring_announcements: true reports_scheduler_cron_timezone: "America/Vancouver" first_year_with_prev_reporting_year_option: "2025" admin_invitation_duration_in_hours: "72" diff --git a/charts/fin-pay-transparency/values-prod.yaml b/charts/fin-pay-transparency/values-prod.yaml index 1d9e274af..8e1e88352 100644 --- a/charts/fin-pay-transparency/values-prod.yaml +++ b/charts/fin-pay-transparency/values-prod.yaml @@ -47,6 +47,8 @@ global: delete_user_errors_cron_crontime: "30 0 1 */6 *" #At 12:30 AM on the 1st day of every 6th month lock_report_cron_crontime: "0 15 0 * * *" # 12:15 AM PST/PDT expire_announcements_cron_crontime: "* 6,18 * * *" # 6am & 6pm daily + email_expiring_announcements_cron_crontime: "0 7 0 * * *" # 7:00 AM PST/PDT + enable_email_expiring_announcements: true reports_scheduler_cron_timezone: "America/Vancouver" first_year_with_prev_reporting_year_option: "2025" admin_invitation_duration_in_hours: "72" diff --git a/charts/fin-pay-transparency/values-test.yaml b/charts/fin-pay-transparency/values-test.yaml index 43037aff9..26e7b1aca 100644 --- a/charts/fin-pay-transparency/values-test.yaml +++ b/charts/fin-pay-transparency/values-test.yaml @@ -47,6 +47,8 @@ global: delete_user_errors_cron_crontime: "30 0 1 */6 *" #At 12:30 AM on the 1st day of every 6th month lock_report_cron_crontime: "0 15 0 * * *" # 12:15 AM PST/PDT expire_announcements_cron_crontime: "* 6,18 * * *" # 6am & 6pm daily + email_expiring_announcements_cron_crontime: "0 7 0 * * *" # 7:00 AM PST/PDT + enable_email_expiring_announcements: true reports_scheduler_cron_timezone: "America/Vancouver" first_year_with_prev_reporting_year_option: "2025" admin_invitation_duration_in_hours: "72" diff --git a/charts/fin-pay-transparency/values.yaml b/charts/fin-pay-transparency/values.yaml index c12996c02..72b9cf2d5 100644 --- a/charts/fin-pay-transparency/values.yaml +++ b/charts/fin-pay-transparency/values.yaml @@ -48,6 +48,8 @@ global: delete_user_errors_cron_crontime: "30 0 1 */6 *" #At 12:30 AM on the 1st day of every 6th month lock_report_cron_crontime: "0 15 0 * * *" # 12:15 AM PST/PDT expire_announcements_cron_crontime: "* 6,18 * * *" # 6am & 6pm daily + email_expiring_announcements_cron_crontime: "0 7 0 * * *" # 7:00 AM PST/PDT + enable_email_expiring_announcements: true reports_scheduler_cron_timezone: "America/Vancouver" first_year_with_prev_reporting_year_option: "2025" admin_invitation_duration_in_hours: "72"