diff --git a/app/backend/lib/emails/handleEmailNotification.ts b/app/backend/lib/emails/handleEmailNotification.ts index e9dd16cbe3..726e1586ef 100644 --- a/app/backend/lib/emails/handleEmailNotification.ts +++ b/app/backend/lib/emails/handleEmailNotification.ts @@ -46,6 +46,7 @@ const isAuthorized = (authRole: any) => { 'ccbc_auth_user', 'super_admin', 'cbc_admin', + 'ccbc_service_account', ]; return authRole && authorizedRoles.includes(authRole.pgRole); }; @@ -89,6 +90,7 @@ const handleEmailBatch = async ( try { const token = await getAccessToken(); const emailResult = await sendEmailMerge(token, body, subject, contexts); + console.log(emailResult, contexts, details); if (emailResult) { const emailRecordResults = emailResult.messages.map( (message: any, i: number) => { diff --git a/app/backend/lib/emails/templates/notifyMilestoneReportDue.ts b/app/backend/lib/emails/templates/notifyMilestoneReportDue.ts new file mode 100644 index 0000000000..790aac19bc --- /dev/null +++ b/app/backend/lib/emails/templates/notifyMilestoneReportDue.ts @@ -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 => { + 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: ` +

Hi {{ recipientName | trim }}

+

This is a notification to let you know that one or more Milestone Reports are coming due in 30 days:

+

+

To unsubscribe from these email notifications, email meherzad.romer@gov.bc.ca

+ `, + contexts, + params: { milestoneReportData }, + }; +}; + +export default notifyMilestoneReportDue; diff --git a/app/backend/lib/milestoneDueDate.ts b/app/backend/lib/milestoneDueDate.ts new file mode 100644 index 0000000000..892c7ad57b --- /dev/null +++ b/app/backend/lib/milestoneDueDate.ts @@ -0,0 +1,175 @@ +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); + const timeDiff = dueDate.getTime() - today.getTime(); + const daysDiff = timeDiff / (1000 * 3600 * 24); + return daysDiff >= 30 && daysDiff <= 31; + }; + + // 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 = sowTab2SBySowId.nodes[0] + ?.jsonData as Array; + const milestoneOneDue = milestoneData.find((milestone) => + isWithin30To31Days(milestone.milestone1) + ); + const milestoneTwoDue = milestoneData.find((milestone) => + isWithin30To31Days(milestone.milestone2) + ); + const milestoneThreeDue = milestoneData.find((milestone) => + 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(), + }); + applicationRowIdsVisited.add(applicationRowId); + } + } + if (milestoneTwoDue) { + const applicationRowId = applicationId; + if (!applicationRowIdsVisited.has(applicationRowId)) { + acc.push({ + applicationRowId, + ccbcNumber, + organizationName, + projectName, + milestoneNumber: '2', + milestoneDate: new Date( + milestoneTwoDue.milestone2 + ).toLocaleDateString(), + }); + applicationRowIdsVisited.add(applicationRowId); + } + } + if (milestoneThreeDue) { + const applicationRowId = applicationId; + if (!applicationRowIdsVisited.has(applicationRowId)) { + acc.push({ + applicationRowId, + ccbcNumber, + organizationName, + projectName, + milestoneNumber: '3', + milestoneDate: new Date( + milestoneThreeDue.milestone3 + ).toLocaleDateString(), + }); + applicationRowIdsVisited.add(applicationRowId); + } + } + 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; diff --git a/app/server.ts b/app/server.ts index 36be0ba3b7..f35750aaae 100644 --- a/app/server.ts +++ b/app/server.ts @@ -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 @@ -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)); diff --git a/app/tests/backend/lib/emails/templates/notifyMilestoneReportDue.test.ts b/app/tests/backend/lib/emails/templates/notifyMilestoneReportDue.test.ts new file mode 100644 index 0000000000..71f87c02af --- /dev/null +++ b/app/tests/backend/lib/emails/templates/notifyMilestoneReportDue.test.ts @@ -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: 'analyst1@example.com', + givenName: 'Analyst One', + }, + { + email: 'analyst2@example.com', + 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(['analyst1@example.com']); + 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(['analyst2@example.com']); + 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( + '

Hi {{ recipientName | trim }}

' + ); + }); +}); diff --git a/app/tests/backend/lib/milestoneDueDate.test.ts b/app/tests/backend/lib/milestoneDueDate.test.ts new file mode 100644 index 0000000000..714c96d9da --- /dev/null +++ b/app/tests/backend/lib/milestoneDueDate.test.ts @@ -0,0 +1,163 @@ +/** + * @jest-environment node + */ +import { mocked } from 'jest-mock'; +import request from 'supertest'; +import express from 'express'; +import session from 'express-session'; +import crypto from 'crypto'; +import milestoneDueDate from '../../../backend/lib/milestoneDueDate'; +import { performQuery } from '../../../backend/lib/graphql'; +import handleEmailNotification from '../../../backend/lib/emails/handleEmailNotification'; +import getAuthRole from '../../../utils/getAuthRole'; + +jest.mock('../../../backend/lib/graphql'); +jest.mock('../../../utils/getAuthRole'); +jest.mock('../../../backend/lib/emails/handleEmailNotification'); + +jest.setTimeout(100000); + +describe('The Milestone excel import api route', () => { + let app; + + beforeEach(async () => { + app = express(); + app.use(session({ secret: crypto.randomUUID(), cookie: { secure: true } })); + app.use('/', milestoneDueDate); + }); + + it('should receive the correct response for unauthorized user', async () => { + mocked(getAuthRole).mockImplementation(() => { + return { + pgRole: 'ccbc_guest', + landingRoute: '/', + }; + }); + + const response = await request(app).get('/api/analyst/milestone/upcoming'); + expect(response.status).toBe(404); + }); + + it('should process milestones for authorized user', async () => { + mocked(getAuthRole).mockImplementation(() => { + return { + pgRole: 'ccbc_admin', + landingRoute: '/', + }; + }); + + mocked(performQuery).mockImplementation(async () => { + return { + data: { + allApplicationSowData: { + nodes: [ + { + applicationId: 1, + applicationByApplicationId: { + ccbcNumber: 'CCBC-010001', + organizationName: 'Organization Name', + projectName: 'Project Name', + }, + sowTab2SBySowId: { + nodes: [ + { + jsonData: [ + { + milestone1: '2022-01-01', + milestone2: '2022-01-02', + }, + ], + }, + { + jsonData: [ + { + milestone1: '2022-01-02', + milestone2: '2022-01-01', + milestone3: '2022-01-03', + }, + ], + }, + ], + }, + }, + ], + }, + }, + }; + }); + + const response = await request(app) + .get('/api/analyst/milestone/upcoming') + .expect(200); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ message: 'No milestones due in 30 days' }); + }); + + it('should process milestones for authorized user and send email', async () => { + mocked(getAuthRole).mockImplementation(() => { + return { + pgRole: 'ccbc_admin', + landingRoute: '/', + }; + }); + + const mockDate = new Date('2021-12-03T00:00:00Z'); + const OriginalDate = Date; + + global.Date = jest.fn((dateString) => { + if (dateString === undefined) { + return mockDate; + } + return new OriginalDate(dateString); + }) as unknown as DateConstructor; + global.Date.now = OriginalDate.now; + + mocked(handleEmailNotification).mockImplementation(async (req, res) => { + return res.status(200).json({ emails: 'sent' }).end(); + }); + + mocked(performQuery).mockImplementation(async () => { + return { + data: { + allApplicationSowData: { + nodes: [ + { + applicationId: 1, + applicationByApplicationId: { + ccbcNumber: 'CCBC-010001', + organizationName: 'Organization Name', + projectName: 'Project Name', + }, + sowTab2SBySowId: { + nodes: [ + { + jsonData: [ + { + milestone1: '2022-01-02', + milestone2: '2022-01-02', + milestone3: '2022-01-02', + }, + ], + }, + ], + }, + }, + ], + }, + }, + }; + }); + + const response = await request(app).get('/api/analyst/milestone/upcoming'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ emails: 'sent' }); + }); + + afterEach(async () => { + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(() => resolve(), 500)); // avoid jest open handle error + }); + jest.resetAllMocks(); +}); diff --git a/cron/sp/app.mjs b/cron/sp/app.mjs index 19a99c750d..acbc7f1569 100644 --- a/cron/sp/app.mjs +++ b/cron/sp/app.mjs @@ -14,6 +14,7 @@ const clientSecret = process.env.SA_CLIENT_SECRET || ''; function fetchAccessToken() { const data = stringify({ grant_type: 'client_credentials', + scope: 'openid', }); const authHeader = `Basic ${Buffer.from( @@ -26,21 +27,22 @@ function fetchAccessToken() { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(data), Authorization: authHeader, }, }; return new Promise((resolve, reject) => { const req = https.request(options, (res) => { - let data = ''; + let responseData = ''; res.on('data', (chunk) => { - data += chunk; + responseData += chunk; }); res.on('end', () => { try { - const tokenData = JSON.parse(data); + const tokenData = JSON.parse(responseData); const accessToken = tokenData.access_token; resolve(accessToken); } catch (error) { @@ -99,6 +101,7 @@ async function main() { console.log('Response', response); } catch (error) { console.error('Error:', error); + process.exitCode = 1; } } diff --git a/db/deploy/grant_read_access_to_service_account.sql b/db/deploy/grant_read_access_to_service_account.sql new file mode 100644 index 0000000000..b858d72a75 --- /dev/null +++ b/db/deploy/grant_read_access_to_service_account.sql @@ -0,0 +1,30 @@ +-- Deploy ccbc:grant_read_access_to_service_account to pg + +BEGIN; + +grant execute on function ccbc_public.application_organization_name to ccbc_service_account; +grant execute on function ccbc_public.application_project_name to ccbc_service_account; +grant execute on function ccbc_public.application_form_data to ccbc_service_account; + +do +$grant_service$ +begin + +-- table grants +perform ccbc_private.grant_permissions('select', 'application', 'ccbc_service_account'); +perform ccbc_private.grant_permissions('select', 'application_form_data', 'ccbc_service_account'); +perform ccbc_private.grant_permissions('select', 'form_data', 'ccbc_service_account'); +perform ccbc_private.grant_permissions('select', 'form', 'ccbc_service_account'); +perform ccbc_private.grant_permissions('select', 'analyst', 'ccbc_service_account'); +perform ccbc_private.grant_permissions('select', 'application_sow_data', 'ccbc_service_account'); +perform ccbc_private.grant_permissions('select', 'sow_tab_2', 'ccbc_service_account'); + +-- RLS +perform ccbc_private.upsert_policy('ccbc_service_account_select_application', 'application', 'select', 'ccbc_service_account', 'true'); +perform ccbc_private.upsert_policy('ccbc_service_account_select_application_form_data', 'application_form_data', 'select', 'ccbc_service_account', 'true'); +perform ccbc_private.upsert_policy('ccbc_service_account_select_form_data', 'application_form_data', 'select', 'ccbc_service_account', 'true'); + +end +$grant_service$; + +COMMIT; diff --git a/db/revert/grant_read_access_to_service_account.sql b/db/revert/grant_read_access_to_service_account.sql new file mode 100644 index 0000000000..130b5da79a --- /dev/null +++ b/db/revert/grant_read_access_to_service_account.sql @@ -0,0 +1,7 @@ +-- Revert ccbc:grant_read_access_to_service_account from pg + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; diff --git a/db/sqitch.plan b/db/sqitch.plan index 5d05c0bdd3..09584fff84 100644 --- a/db/sqitch.plan +++ b/db/sqitch.plan @@ -715,3 +715,4 @@ tables/application_form_template_9_data 2024-09-25T22:50:17Z Rafael Solorzano <6 @1.202.0 2024-10-21T21:32:01Z CCBC Service Account # release v1.202.0 @1.202.1 2024-10-23T14:59:42Z CCBC Service Account # release v1.202.1 @1.202.2 2024-10-23T20:54:15Z CCBC Service Account # release v1.202.2 +grant_read_access_to_service_account 2024-10-04T18:45:20Z Anthony Bushara # Add read access to service account diff --git a/helm/app/templates/cronJobs/trigger-milestone-notification.yaml b/helm/app/templates/cronJobs/trigger-milestone-notification.yaml new file mode 100644 index 0000000000..5cc3590361 --- /dev/null +++ b/helm/app/templates/cronJobs/trigger-milestone-notification.yaml @@ -0,0 +1,50 @@ +{{- if .Values.deploy.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ template "ccbc.fullname" . }}-cron-milestone + labels: {{ include "ccbc.labels" . | nindent 4 }} +spec: + suspend: true + schedule: {{ .Values.cronmilestone.schedule }} + timeZone: "America/Vancouver" + jobTemplate: + spec: + backoffLimit: 0 + activeDeadlineSeconds: 600 + template: + metadata: + labels: {{ include "ccbc.labels" . | nindent 14 }} + spec: + restartPolicy: Never + containers: + - env: + - name: KEYCLOAK_HOST + value: {{ .Values.cronsp.keycloakHost | quote }} + - name: CCBC_SERVER_PATH + value: {{ .Values.cronmilestone.path | quote }} + - name: CCBC_SERVER_HOST + value: {{ template "ccbc.fullname" . }} + - name: CCBC_SERVER_PORT + value: {{ .Values.cronsp.port | quote }} + - name: SA_CLIENT_ID + valueFrom: + secretKeyRef: + key: client-id + name: ccbc-cron-sp-sso + - name: SA_CLIENT_SECRET + valueFrom: + secretKeyRef: + key: client-secret + name: ccbc-cron-sp-sso + name: {{ template "ccbc.fullname" . }}-cron-milestone + image: {{ .Values.image.cronsp.repository }}:{{ .Values.image.cronsp.tag }} + imagePullPolicy: IfNotPresent + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 100m + memory: 64Mi +{{- end }} diff --git a/helm/app/values.yaml b/helm/app/values.yaml index d88b5348dc..a02808cc82 100644 --- a/helm/app/values.yaml +++ b/helm/app/values.yaml @@ -46,6 +46,10 @@ cronshp: rdFile: '' # The value must be passed in via the deploy script coveragesFile: '' # The value must be passed in via the deploy script +cronmilestone: + path: '/api/analyst/cron-milestones' + schedule: '0 7 * * *' # Trigger 7AM every day + app: port: '3000' probesPort: '9000'