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

PIMS 1957/1958: Handle agency response change notifications #2610

Merged
Merged
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const deleteDisposalProject = async (req: Request, res: Response) => {
projectId,
req.user.preferred_username,
);
const notifications = await notificationServices.cancelAllProjectNotifications(projectId);
const notifications = await notificationServices.cancelProjectNotifications(projectId);

return res.status(200).send({ project: delProject, notifications });
};
Expand Down
205 changes: 173 additions & 32 deletions express-api/src/services/notifications/notificationServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ProjectStatusNotification } from '@/typeorm/Entities/ProjectStatusNotif
import { User } from '@/typeorm/Entities/User';
import { UUID, randomUUID } from 'crypto';
import nunjucks from 'nunjucks';
import { In, IsNull, QueryRunner } from 'typeorm';
import { In, IsNull, MoreThan, QueryRunner } from 'typeorm';
import chesServices, {
EmailBody,
EmailEncoding,
Expand All @@ -19,6 +19,8 @@ import { SSOUser } from '@bcgov/citz-imb-sso-express';
import { ProjectAgencyResponse } from '@/typeorm/Entities/ProjectAgencyResponse';
import logger from '@/utilities/winstonLogger';
import getConfig from '@/constants/config';
import { getDaysBetween } from '@/utilities/helperFunctions';
import { ProjectStatusHistory } from '@/typeorm/Entities/ProjectStatusHistory';

export interface AccessRequestData {
FirstName: string;
Expand Down Expand Up @@ -154,17 +156,22 @@ const insertProjectNotificationQueue = async (
project: Project,
agency?: Agency,
overrideTo?: string,
overrideSend?: Date,
queryRunner?: QueryRunner,
) => {
const query = queryRunner ?? AppDataSource.createQueryRunner();
const sendDate = new Date();
sendDate.setDate(sendDate.getDate() + projStatusNotif.DelayDays);
let emailSendDate = overrideSend;
if (emailSendDate == undefined) {
const sendDelayFromToday = new Date();
sendDelayFromToday.setDate(sendDelayFromToday.getDate() + projStatusNotif.DelayDays);
emailSendDate = sendDelayFromToday;
}
const queueObject = {
Key: randomUUID(),
Status: NotificationStatus.Pending,
Priority: template.Priority,
Encoding: template.Encoding,
SendOn: sendDate,
SendOn: emailSendDate,
Subject: nunjucks.renderString(template.Subject, { Project: project }),
BodyType: template.BodyType,
Body: nunjucks.renderString(template.Body, {
Expand All @@ -181,11 +188,11 @@ const insertProjectNotificationQueue = async (
ProjectId: project.Id,
ToAgencyId: agency?.Id,
};
const insertedNotif = await query.manager.save(NotificationQueue, queueObject);
if (queryRunner === undefined) {
//If no arg passed we spawned a new query runner and we must release that!
await query.release();
}
const insertedNotif = await query.manager.save(NotificationQueue, queueObject);
return insertedNotif;
};

Expand Down Expand Up @@ -228,6 +235,7 @@ const generateProjectNotifications = async (
project,
project.Agency,
overrideTo,
undefined,
queryRunner,
),
);
Expand All @@ -239,6 +247,7 @@ const generateProjectNotifications = async (
project,
project.Agency,
undefined,
undefined,
queryRunner,
),
);
Expand Down Expand Up @@ -271,6 +280,7 @@ const generateProjectNotifications = async (
project,
agc,
undefined,
undefined,
queryRunner,
),
),
Expand Down Expand Up @@ -305,6 +315,7 @@ const generateProjectNotifications = async (
project,
agc,
undefined,
undefined,
queryRunner,
),
),
Expand Down Expand Up @@ -334,6 +345,7 @@ const generateProjectNotifications = async (
project,
agc,
undefined,
undefined,
queryRunner,
),
),
Expand All @@ -346,6 +358,7 @@ const generateProjectNotifications = async (
project,
undefined,
undefined,
undefined,
queryRunner,
),
);
Expand Down Expand Up @@ -522,42 +535,170 @@ const getProjectNotificationsInQueue = async (
return pageModel;
};

const cancelAllProjectNotifications = async (projectId: number) => {
const notifications = await AppDataSource.getRepository(NotificationQueue).find({
where: [
{ ProjectId: projectId, Status: NotificationStatus.Accepted },
{ ProjectId: projectId, Status: NotificationStatus.Pending },
],
});
const chesCancelPromises = notifications.map((notification) => {
return chesServices.cancelEmailByIdAsync(notification.ChesMessageId);
});
const chesCancelResolved = await Promise.allSettled(chesCancelPromises);
const cancelledMessageIds = chesCancelResolved
.filter((a) => a.status === 'fulfilled' && a.value.status === 'cancelled')
.map((c) => (c as PromiseFulfilledResult<IChesStatusResponse>).value.msgId);
await AppDataSource.getRepository(NotificationQueue).update(
{ ChesMessageId: In(cancelledMessageIds) },
{ Status: convertChesStatusToNotificationStatus('cancelled') },
);
return {
succeeded: chesCancelResolved.filter(
(c) => c.status === 'fulfilled' && c.value.status === 'cancelled',
).length,
failed: chesCancelResolved.filter(
(c) => c.status === 'rejected' || c.value?.status !== 'cancelled',
).length,
};
const cancelProjectNotifications = async (
projectId: number,
agencyId?: number,
queryRunner?: QueryRunner,
) => {
const query = queryRunner ?? AppDataSource.createQueryRunner();
try {
const notifications = await query.manager.find(NotificationQueue, {
where: [
{ ProjectId: projectId, Status: NotificationStatus.Accepted, ToAgencyId: agencyId },
{ ProjectId: projectId, Status: NotificationStatus.Pending, ToAgencyId: agencyId },
],
});
const chesCancelPromises = notifications.map((notification) => {
return chesServices.cancelEmailByIdAsync(notification.ChesMessageId);
});
const chesCancelResolved = await Promise.allSettled(chesCancelPromises);
const cancelledMessageIds = chesCancelResolved
.filter((a) => a.status === 'fulfilled' && a.value.status === 'cancelled')
.map((c) => (c as PromiseFulfilledResult<IChesStatusResponse>).value.msgId);
await query.manager.update(
NotificationQueue,
{ ChesMessageId: In(cancelledMessageIds) },
{ Status: convertChesStatusToNotificationStatus('cancelled') },
);
return {
succeeded: chesCancelResolved.filter(
(c) => c.status === 'fulfilled' && c.value.status === 'cancelled',
).length,
failed: chesCancelResolved.filter(
(c) => c.status === 'rejected' || c.value?.status !== 'cancelled',
).length,
};
} catch (e) {
logger.error(`Error: Something went wrong when trying to cancel project notifications.`);
return {
succeeded: 0,
failed: 0,
};
} finally {
if (queryRunner === undefined) {
query.release();
}
}
};

/**
* Generates project notifications based off agency responses.
* Agencies that opt out of notifications will have any pending notifications cancelled here.
* Agencies that were not previously registered for notifications will have any delayed notifications,
* such as 30,60,90 ERP reminders, queued as if they had been registered for these from the start.
* @param project Up to date project entity.
* @param responses Agency responses to process based off response type.
* @param queryRunner Optional queryRunner, include if inside transaction.
* @returns {NotificationQueue[]} A list of notifications queued by this function call
*/
const generateProjectWatchNotifications = async (
project: Project,
responses: ProjectAgencyResponse[],
queryRunner?: QueryRunner,
) => {
const query = queryRunner ?? AppDataSource.createQueryRunner();
const notificationsInserted: Array<NotificationQueue> = [];
try {
for (const response of responses) {
switch (response.Response) {
case AgencyResponseType.Unsubscribe:
case AgencyResponseType.Watch:
//The simple case. Calling this will cancel all pending notifications for this project/agency pair.
await cancelProjectNotifications(response.ProjectId, response.AgencyId);
break;
case AgencyResponseType.Subscribe: {
const agency = await query.manager.findOne(Agency, {
where: { Id: response.AgencyId },
});
//No use in queueing an email for an agency with no email address.
if (agency?.Email) {
/*We get the most recent entry in the status history since the date value of this row would have been populated
at the time this project was placed into its current status value. Note that this value is not necessarily the same as
Project.UpdatedOn, as projects can be updated without the status being changed. */
const mostRecentStatusChange = await query.manager.findOne(ProjectStatusHistory, {
where: { ProjectId: project.Id },
order: { CreatedOn: 'DESC' },
});

const mostRecentStatusChangeDate =
mostRecentStatusChange?.CreatedOn ?? project.CreatedOn;
const daysSinceThisStatus = getDaysBetween(mostRecentStatusChangeDate, new Date());
const statusNotifs = await query.manager.find(ProjectStatusNotification, {
relations: {
Template: true,
},
where: {
ToStatusId: project.StatusId, //We will only send notifications relevant to the current status.
Template: {
Audience: NotificationAudience.WatchingAgencies,
},
DelayDays: MoreThan(daysSinceThisStatus),
},
});
for (const statusNotif of statusNotifs) {
//If there is already a pending notification for this agency with this template, skip.
const notifExists = await query.manager.exists(NotificationQueue, {
where: [
{
ProjectId: project.Id,
ToAgencyId: response.AgencyId,
TemplateId: statusNotif.TemplateId,
Status: NotificationStatus.Accepted,
},
{
ProjectId: project.Id,
ToAgencyId: response.AgencyId,
TemplateId: statusNotif.TemplateId,
Status: NotificationStatus.Pending,
},
],
});

if (!notifExists) {
//If there is no notification like this already pending, we send one.
const sendOn = new Date(mostRecentStatusChangeDate.getTime());
sendOn.setDate(mostRecentStatusChangeDate.getDate() + statusNotif.DelayDays);
//We set the delay by the most recent status change date plus the number of delay days. This should make these new emails
//send around the same time as the emails that previously sent when this project changed into its current status.
const inserted = await insertProjectNotificationQueue(
statusNotif.Template,
statusNotif,
project,
agency,
undefined,
sendOn,
queryRunner,
);
notificationsInserted.push(inserted);
}
}
}
break;
}
}
}
} catch (e) {
logger.error(
`Error: Some notification actions triggered by an agency response may have failed to cancel or update. Project ID: ${project.Id}, Error msg: ${e.message}`,
);
logger.error(e.stack);
} finally {
if (queryRunner === undefined) {
query.release();
}
}
return notificationsInserted;
};

const notificationServices = {
generateProjectNotifications,
generateAccessRequestNotification,
generateProjectWatchNotifications,
sendNotification,
updateNotificationStatus,
getProjectNotificationsInQueue,
convertChesStatusToNotificationStatus,
cancelAllProjectNotifications,
cancelProjectNotifications,
};

export default notificationServices;
Loading
Loading