270 lines
16 KiB
JavaScript
270 lines
16 KiB
JavaScript
const { Queue, Worker } = require("bullmq");
|
|
const { sendTaskEmail } = require("../../email/sendemail");
|
|
const generateEmailTemplate = require("../../email/generateTemplate");
|
|
const { InstanceEndpoints } = require("../../utils/instanceMgr");
|
|
const { registerCleanupTask } = require("../../utils/cleanupManager");
|
|
const getBullMQPrefix = require("../../utils/getBullMQPrefix");
|
|
const devDebugLogger = require("../../utils/devDebugLogger");
|
|
const moment = require("moment-timezone");
|
|
|
|
const EMAIL_CONSOLIDATION_DELAY_IN_MINS = (() => {
|
|
const envValue = process.env?.EMAIL_CONSOLIDATION_DELAY_IN_MINS;
|
|
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
|
|
return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); // Default to 3, ensure at least 1
|
|
})();
|
|
|
|
// Base time-related constant (in milliseconds) / DO NOT TOUCH
|
|
const EMAIL_CONSOLIDATION_DELAY = EMAIL_CONSOLIDATION_DELAY_IN_MINS * 60000; // 1 minute (base timeout)
|
|
|
|
// Derived time-related constants based on EMAIL_CONSOLIDATION_DELAY / DO NOT TOUCH, these are pegged to EMAIL_CONSOLIDATION_DELAY
|
|
const CONSOLIDATION_KEY_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (90s, buffer for consolidation)
|
|
const LOCK_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 0.25; // 15 seconds (quarter of base, for lock duration)
|
|
const RATE_LIMITER_DURATION = EMAIL_CONSOLIDATION_DELAY * 0.1; // 6 seconds (tenth of base, for rate limiting)
|
|
const NOTIFICATION_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (matches consolidation key expiration)
|
|
|
|
let emailAddQueue;
|
|
let emailConsolidateQueue;
|
|
let emailAddWorker;
|
|
let emailConsolidateWorker;
|
|
|
|
/**
|
|
* Initializes the email notification queues and workers.
|
|
*
|
|
* @param {Object} options - Configuration options for queue initialization.
|
|
* @param {Object} options.pubClient - Redis client instance for queue communication.
|
|
* @param {Object} options.logger - Logger instance for logging events and debugging.
|
|
* @returns {Queue} The initialized `emailAddQueue` instance for dispatching notifications.
|
|
*/
|
|
const loadEmailQueue = async ({ pubClient, logger }) => {
|
|
if (!emailAddQueue || !emailConsolidateQueue) {
|
|
const prefix = getBullMQPrefix();
|
|
const devKey = process.env?.NODE_ENV === "production" ? "prod" : "dev";
|
|
|
|
devDebugLogger(`Initializing Email Notification Queues with prefix: ${prefix}`);
|
|
|
|
// Queue for adding email notifications
|
|
emailAddQueue = new Queue("emailAdd", {
|
|
prefix,
|
|
connection: pubClient,
|
|
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
|
|
});
|
|
|
|
// Queue for consolidating and sending emails
|
|
emailConsolidateQueue = new Queue("emailConsolidate", {
|
|
prefix,
|
|
connection: pubClient,
|
|
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
|
|
});
|
|
|
|
// Worker to process adding notifications
|
|
emailAddWorker = new Worker(
|
|
"emailAdd",
|
|
async (job) => {
|
|
const { jobId, jobRoNumber, bodyShopName, bodyShopTimezone, body, recipients } = job.data;
|
|
devDebugLogger(`Adding email notifications for jobId ${jobId}`);
|
|
|
|
const redisKeyPrefix = `email:${devKey}:notifications:${jobId}`;
|
|
|
|
for (const recipient of recipients) {
|
|
const { user, firstName, lastName } = recipient;
|
|
const userKey = `${redisKeyPrefix}:${user}`;
|
|
await pubClient.rpush(userKey, body);
|
|
await pubClient.expire(userKey, NOTIFICATION_EXPIRATION / 1000);
|
|
const detailsKey = `email:${devKey}:recipientDetails:${jobId}:${user}`;
|
|
await pubClient.hsetnx(detailsKey, "firstName", firstName || "");
|
|
await pubClient.hsetnx(detailsKey, "lastName", lastName || "");
|
|
await pubClient.hsetnx(detailsKey, "bodyShopTimezone", bodyShopTimezone);
|
|
await pubClient.expire(detailsKey, NOTIFICATION_EXPIRATION / 1000);
|
|
const recipientsSetKey = `email:${devKey}:recipients:${jobId}`;
|
|
await pubClient.sadd(recipientsSetKey, user);
|
|
await pubClient.expire(recipientsSetKey, NOTIFICATION_EXPIRATION / 1000);
|
|
devDebugLogger(`Stored message for ${user} under ${userKey}: ${body}`);
|
|
}
|
|
|
|
const consolidateKey = `email:${devKey}:consolidate:${jobId}`;
|
|
const flagSet = await pubClient.setnx(consolidateKey, "pending");
|
|
if (flagSet) {
|
|
await emailConsolidateQueue.add(
|
|
"consolidate-emails",
|
|
{ jobId, jobRoNumber, bodyShopName, bodyShopTimezone },
|
|
{
|
|
jobId: `consolidate-${jobId}`,
|
|
delay: EMAIL_CONSOLIDATION_DELAY,
|
|
attempts: 3,
|
|
backoff: LOCK_EXPIRATION
|
|
}
|
|
);
|
|
devDebugLogger(`Scheduled email consolidation for jobId ${jobId}`);
|
|
await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000);
|
|
} else {
|
|
devDebugLogger(`Email consolidation already scheduled for jobId ${jobId}`);
|
|
}
|
|
},
|
|
{
|
|
prefix,
|
|
connection: pubClient,
|
|
concurrency: 5
|
|
}
|
|
);
|
|
|
|
// Worker to consolidate and send emails
|
|
emailConsolidateWorker = new Worker(
|
|
"emailConsolidate",
|
|
async (job) => {
|
|
const { jobId, jobRoNumber, bodyShopName } = job.data;
|
|
devDebugLogger(`Consolidating emails for jobId ${jobId}`);
|
|
|
|
const lockKey = `lock:${devKey}:emailConsolidate:${jobId}`;
|
|
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000);
|
|
if (lockAcquired) {
|
|
try {
|
|
const recipientsSet = `email:${devKey}:recipients:${jobId}`;
|
|
const recipients = await pubClient.smembers(recipientsSet);
|
|
for (const recipient of recipients) {
|
|
const userKey = `email:${devKey}:notifications:${jobId}:${recipient}`;
|
|
const detailsKey = `email:${devKey}:recipientDetails:${jobId}:${recipient}`;
|
|
const messages = await pubClient.lrange(userKey, 0, -1);
|
|
if (messages.length > 0) {
|
|
const details = await pubClient.hgetall(detailsKey);
|
|
const firstName = details.firstName || "User";
|
|
const multipleUpdateString = messages.length > 1 ? "Updates" : "Update";
|
|
const subject = `${multipleUpdateString} for job ${jobRoNumber || "N/A"} at ${bodyShopName}`;
|
|
const timezone = moment.tz.zone(details?.bodyShopTimezone) ? details.bodyShopTimezone : "UTC";
|
|
const emailBody = generateEmailTemplate({
|
|
header: `${multipleUpdateString} for Job ${jobRoNumber || "N/A"}`,
|
|
subHeader: `Dear ${firstName},`,
|
|
dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"),
|
|
body: `
|
|
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 100%;">There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p>
|
|
</td></tr></table></th>
|
|
</tr></tbody></table>
|
|
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
|
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
|
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
|
|
${messages.map((msg) => `<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">${msg}</li>`).join("")}
|
|
</ul>
|
|
</td></tr></table></th>
|
|
</tr><tbody></table>
|
|
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
|
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
|
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;"><a href="${InstanceEndpoints()}/manage/jobs/${jobId}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Please check the job for more details.</a></p>
|
|
`
|
|
});
|
|
await sendTaskEmail({
|
|
to: recipient,
|
|
subject,
|
|
type: "html",
|
|
html: emailBody
|
|
});
|
|
devDebugLogger(
|
|
`Sent consolidated email to ${recipient} for jobId ${jobId} with ${messages.length} updates`
|
|
);
|
|
await pubClient.del(userKey);
|
|
await pubClient.del(detailsKey);
|
|
}
|
|
}
|
|
await pubClient.del(recipientsSet);
|
|
await pubClient.del(`email:${devKey}:consolidate:${jobId}`);
|
|
} catch (err) {
|
|
logger.log(`email-queue-consolidation-error`, "ERROR", "notifications", "api", {
|
|
message: err?.message,
|
|
stack: err?.stack
|
|
});
|
|
throw err;
|
|
} finally {
|
|
await pubClient.del(lockKey);
|
|
}
|
|
} else {
|
|
devDebugLogger(`Skipped email consolidation for jobId ${jobId} - lock held by another worker`);
|
|
}
|
|
},
|
|
{
|
|
prefix,
|
|
connection: pubClient,
|
|
concurrency: 1,
|
|
limiter: { max: 1, duration: RATE_LIMITER_DURATION }
|
|
}
|
|
);
|
|
|
|
// Event handlers for workers
|
|
emailAddWorker.on("completed", (job) => devDebugLogger(`Email add job ${job.id} completed`));
|
|
emailConsolidateWorker.on("completed", (job) => devDebugLogger(`Email consolidate job ${job.id} completed`));
|
|
|
|
emailAddWorker.on("failed", (job, err) =>
|
|
logger.log(`add-email-queue-failed`, "ERROR", "notifications", "api", {
|
|
message: err?.message,
|
|
stack: err?.stack
|
|
})
|
|
);
|
|
emailConsolidateWorker.on("failed", (job, err) =>
|
|
logger.log(`email-consolidation-job-failed`, "ERROR", "notifications", "api", {
|
|
message: err?.message,
|
|
stack: err?.stack
|
|
})
|
|
);
|
|
|
|
// Register cleanup task instead of direct process listeners
|
|
const shutdown = async () => {
|
|
devDebugLogger("Closing email queue workers...");
|
|
await Promise.all([emailAddWorker.close(), emailConsolidateWorker.close()]);
|
|
devDebugLogger("Email queue workers closed");
|
|
};
|
|
registerCleanupTask(shutdown);
|
|
}
|
|
|
|
return emailAddQueue;
|
|
};
|
|
|
|
/**
|
|
* Retrieves the initialized `emailAddQueue` instance.
|
|
*
|
|
* @returns {Queue} The `emailAddQueue` instance for adding notifications.
|
|
* @throws {Error} If `emailAddQueue` is not initialized.
|
|
*/
|
|
const getQueue = () => {
|
|
if (!emailAddQueue) {
|
|
throw new Error("Email add queue not initialized. Ensure loadEmailQueue is called during bootstrap.");
|
|
}
|
|
return emailAddQueue;
|
|
};
|
|
|
|
/**
|
|
* Dispatches email notifications to the `emailAddQueue` for processing.
|
|
*
|
|
* @param {Object} options - Options for dispatching notifications.
|
|
* @param {Array} options.emailsToDispatch - Array of email notification objects.
|
|
* @param {Object} options.logger - Logger instance for logging dispatch events.
|
|
* @returns {Promise<void>} Resolves when all notifications are added to the queue.
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => {
|
|
const emailAddQueue = getQueue();
|
|
|
|
for (const email of emailsToDispatch) {
|
|
const { jobId, bodyShopName, bodyShopTimezone, body, recipients } = email;
|
|
let { jobRoNumber } = email;
|
|
|
|
// Make sure Jobs that have not been coverted yet can still get notifications
|
|
if (jobRoNumber === null) {
|
|
jobRoNumber = "N/A";
|
|
}
|
|
|
|
if (!jobId || !jobRoNumber || !bodyShopName || !body || !recipients.length) {
|
|
devDebugLogger(
|
|
`Skipping email dispatch for jobId ${jobId} due to missing data: ` +
|
|
`jobRoNumber=${jobRoNumber || "N/A"}, bodyShopName=${bodyShopName}, body=${body}, recipients=${recipients.length}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
await emailAddQueue.add(
|
|
"add-email-notification",
|
|
{ jobId, jobRoNumber, bodyShopName, bodyShopTimezone, body, recipients },
|
|
{ jobId: `${jobId}-${Date.now()}` }
|
|
);
|
|
devDebugLogger(`Added email notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
|
|
}
|
|
};
|
|
|
|
module.exports = { loadEmailQueue, getQueue, dispatchEmailsToQueue };
|