From bb205af01943e9976526b8187c672266ce2efd20 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 16 Apr 2024 15:36:27 -0400 Subject: [PATCH] - Progress Signed-off-by: Dave Richer --- .../task-upsert-modal.component.jsx | 3 + .../task-upsert-modal.container.jsx | 49 +--- client/src/graphql/tasks.queries.js | 5 +- hasura/metadata/cron_triggers.yaml | 9 +- server/email/sendemail.js | 100 ++++---- server/email/tasksEmails.js | 223 ++++++++++++++++++ server/graphql-client/queries.js | 34 +++ server/routes/miscellaneousRoutes.js | 5 +- 8 files changed, 323 insertions(+), 105 deletions(-) create mode 100644 server/email/tasksEmails.js diff --git a/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx index 2c5eff0a7..22ec4fec9 100644 --- a/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx +++ b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx @@ -266,6 +266,9 @@ export function TaskUpsertModalComponent({ + Please sign in to your account to view the task details.` - // } - // }) - // .catch((e) => - // console.error(`Something went wrong sending email to Assigned party on Task creation. ${e.message || ""}`) - // ); - // } - notification["success"]({ message: t("tasks.successes.updated") }); @@ -233,30 +209,6 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to form.resetFields(); toggleModalVisible(); - // send notification to the assigned user - // TODO: This is being moved serverside - // axios - // .post("/sendemail", { - // from: { - // name: bodyshop.shopname, - // address: bodyshop.email - // }, - // replyTo: { - // Email: "noreply@imex.online" - // }, - // to: values.assigned_to, - // subject: `A new Task has been assigned to you on ${bodyshop.shopname} - ${values.title}`, - // templateName: "taskAssigned", - // templateStrings: { - // header: values.title, - // subHeader: `Assigned by ${currentUser.email} ${values.due_at ? `| Due on ${dayjs(values.due_at).format("MM/DD/YYYY")}` : ""}`, - // body: `Please sign to your account to view the task details.` - // } - // }) - // .catch((e) => - // console.error(`Something went wrong sending email to Assigned party on Task edit. ${e.message || ""}`) - // ); - notification["success"]({ message: t("tasks.successes.created") }); @@ -276,6 +228,7 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to } return acc; }, {}); + console.dir(dirtyValues?.remind_at_sent); try { await handleExistingTask(taskSource.id, taskSource.jobid, dirtyValues); } catch (e) { diff --git a/client/src/graphql/tasks.queries.js b/client/src/graphql/tasks.queries.js index 60fe01fd9..94debb87c 100644 --- a/client/src/graphql/tasks.queries.js +++ b/client/src/graphql/tasks.queries.js @@ -15,6 +15,7 @@ export const PARTIAL_TASK_FIELDS = gql` completed completed_at remind_at + remind_at_sent priority job { id @@ -63,10 +64,6 @@ export const PARTIAL_TASK_FIELDS = gql` } `; -export const PARTIAL_TASK_FIELDS_WRAPPER = gql` - ${PARTIAL_TASK_FIELDS} -`; - export const QUERY_GET_TASK_BY_ID = gql` ${PARTIAL_TASK_FIELDS} query QUERY_GET_TASK_BY_ID($id: uuid!) { diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml index fe51488c7..b5507b288 100644 --- a/hasura/metadata/cron_triggers.yaml +++ b/hasura/metadata/cron_triggers.yaml @@ -1 +1,8 @@ -[] +- name: Task Reminders + webhook: https://worktest.home.irony.online/tasks-remind-handler + schedule: '*/1 * * * *' + include_in_metadata: true + payload: {} + headers: + - name: event-secret + value: DevelopmentEventSecret diff --git a/server/email/sendemail.js b/server/email/sendemail.js index f26ba5315..85534b815 100644 --- a/server/email/sendemail.js +++ b/server/email/sendemail.js @@ -12,6 +12,7 @@ const client = require("../graphql-client/graphql-client").client; const queries = require("../graphql-client/queries"); const { isObject } = require("lodash"); const generateEmailTemplate = require("./generateTemplate"); +const moment = require("moment"); const ses = new aws.SES({ // The key apiVersion is no longer supported in v3, and can be removed. @@ -23,12 +24,46 @@ const ses = new aws.SES({ rome: "us-east-2" }) }); - let transporter = nodemailer.createTransport({ SES: { ses, aws } }); -exports.sendServerEmail = async function ({ subject, text }) { +// Get the image from the URL and return it as a base64 string +const getImage = async (imageUrl) => { + let image = await axios.get(imageUrl, { responseType: "arraybuffer" }); + let raw = Buffer.from(image.data).toString("base64"); + return "data:" + image.headers["content-type"] + ";base64," + raw; +}; + +// Log the email in the database +const logEmail = async (req, email) => { + try { + const insertresult = await client.request(queries.INSERT_EMAIL_AUDIT, { + email: { + to: email.to, + cc: email.cc, + subject: email.subject, + bodyshopid: req.body.bodyshopid, + useremail: req.user.email, + contents: req.body.html, + jobid: req.body.jobid, + sesmessageid: email.messageId, + status: "Sent" + } + }); + console.log(insertresult); + } catch (error) { + logger.log("email-log-error", "error", req.user.email, null, { + from: `${req.body.from.name} <${req.body.from.address}>`, + to: req.body.to, + cc: req.body.cc, + subject: req.body.subject + // info, + }); + } +}; + +const sendServerEmail = async ({ subject, text }) => { if (process.env.NODE_ENV === undefined) return; try { transporter.sendMail( @@ -60,7 +95,8 @@ exports.sendServerEmail = async function ({ subject, text }) { logger.log("server-email-failure", "error", null, null, error); } }; -exports.sendTaskEmail = async function ({ to, subject, text, attachments }) { + +const sendTaskEmail = async ({ to, subject, text, attachments }) => { try { transporter.sendMail( { @@ -84,19 +120,8 @@ exports.sendTaskEmail = async function ({ to, subject, text, attachments }) { } }; -// This will be called by a Hasura event trigger -exports.taskAssignedEmail = async function (req, res) { - console.dir(req, { depth: null }); - return res.status(200).json(req); -}; - -// This will be called by a Hasura event trigger -exports.tasksRemindEmail = async function (req, res) { - console.dir(req, { depth: null }); - return res.status(200).json(req); -}; - -exports.sendEmail = async (req, res) => { +// Send an email +const sendEmail = async (req, res) => { logger.log("send-email", "DEBUG", req.user.email, null, { from: `${req.body.from.name} <${req.body.from.address}>`, replyTo: req.body.ReplyTo.Email, @@ -204,40 +229,8 @@ exports.sendEmail = async (req, res) => { ); }; -async function getImage(imageUrl) { - let image = await axios.get(imageUrl, { responseType: "arraybuffer" }); - let raw = Buffer.from(image.data).toString("base64"); - return "data:" + image.headers["content-type"] + ";base64," + raw; -} - -async function logEmail(req, email) { - try { - const insertresult = await client.request(queries.INSERT_EMAIL_AUDIT, { - email: { - to: email.to, - cc: email.cc, - subject: email.subject, - bodyshopid: req.body.bodyshopid, - useremail: req.user.email, - contents: req.body.html, - jobid: req.body.jobid, - sesmessageid: email.messageId, - status: "Sent" - } - }); - console.log(insertresult); - } catch (error) { - logger.log("email-log-error", "error", req.user.email, null, { - from: `${req.body.from.name} <${req.body.from.address}>`, - to: req.body.to, - cc: req.body.cc, - subject: req.body.subject - // info, - }); - } -} - -exports.emailBounce = async function (req, res) { +// This will be called by an SNS event trigger +const emailBounce = async (req, res) => { try { const body = JSON.parse(req.body); if (body.Type === "SubscriptionConfirmation") { @@ -311,3 +304,10 @@ ${body.bounce?.bouncedRecipients.map( } res.sendStatus(200); }; + +module.exports = { + sendEmail, + sendServerEmail, + sendTaskEmail, + emailBounce +}; diff --git a/server/email/tasksEmails.js b/server/email/tasksEmails.js new file mode 100644 index 000000000..18625d8dd --- /dev/null +++ b/server/email/tasksEmails.js @@ -0,0 +1,223 @@ +const path = require("path"); +require("dotenv").config({ + path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) +}); +let nodemailer = require("nodemailer"); +let aws = require("@aws-sdk/client-ses"); +let { defaultProvider } = require("@aws-sdk/credential-provider-node"); +const InstanceManager = require("../utils/instanceMgr").default; +const logger = require("../utils/logger"); +const client = require("../graphql-client/graphql-client").client; +const queries = require("../graphql-client/queries"); +const generateEmailTemplate = require("./generateTemplate"); +const moment = require("moment"); +const { UPDATE_TASKS_REMIND_AT_SENT } = require("../graphql-client/queries"); + +const ses = new aws.SES({ + apiVersion: "latest", + defaultProvider, + region: InstanceManager({ + imex: "ca-central-1", + rome: "us-east-2" + }) +}); + +const transporter = nodemailer.createTransport({ + SES: { ses, aws }, + sendingRate: 40 // 40 emails per second. +}); + +const fromEmails = InstanceManager({ + imex: `ImEX Online `, + rome: `Rome Online `, + promanager: `ProManager ` +}); + +/** + * Format the date for the email. + * @param date + * @returns {string|string} + */ +const formatDate = (date) => { + return date ? `| Due on ${moment(date).format("MM/DD/YYYY")}` : ""; +}; + +/** + * Generate the email template arguments. + * @param title + * @param createdBy + * @param dueDate + * @param taskId + * @returns {{header, body: string, subHeader: string}} + */ +const generateTemplateArgs = (title, createdBy, dueDate, taskId) => { + return { + header: title, + subHeader: `Assigned by ${createdBy} ${formatDate(dueDate)}`, + body: `Please sign in to your account to view the Task details.` + }; +}; + +/** + * Send the email. + * @param type + * @param to + * @param subject + * @param html + * @param taskIds + * @param successCallback + */ +const sendMail = (type, to, subject, html, taskIds, successCallback) => { + // Push next messages to Nodemailer + transporter.once("idle", () => { + if (transporter.isIdle()) { + transporter.sendMail( + { + from: fromEmails, + to, + subject, + html + }, + (error, info) => { + if (info) { + if (typeof successCallback === "function" && taskIds && taskIds.length) { + successCallback(taskIds); + } + } else { + logger.log(`task-${type}-email-failure`, "error", null, null, error); + } + } + ); + } + }); +}; + +/** + * Send an email to the assigned user. + * @param req + * @param res + * @returns {Promise<*>} + */ +const taskAssignedEmail = async (req, res) => { + // We have no event Data, bail + if (!req?.payload?.event?.data?.new) { + return res.status(400).json({ message: "No data in the event payload" }); + } + + const { new: newTask } = req.payload.event.data; + + // This is not a new task, but a reassignment. + const dirty = req.payload.event.data?.old && req.payload.event.data?.old?.assigned_to; + + sendMail( + "assigned", + newTask.assigned_to, + `A Task has been ${dirty ? "reassigned" : "created"} for you - ${newTask.title}`, + generateEmailTemplate(generateTemplateArgs(newTask.title, newTask.created_by, newTask.due_date, newTask.id)) + ); + + // We return success regardless because we don't want to block the event trigger. + res.status(200).json({ success: true }); +}; + +/** + * Send an email to remind the user of their tasks. + * @param req + * @param res + * @returns {Promise<*>} + */ +const tasksRemindEmail = async (req, res) => { + try { + const tasksRequest = await client.request(queries.QUERY_REMIND_TASKS, { + time: moment().add(1, "minutes").toISOString() + }); + + // No tasks present in the database, bail. + if (!tasksRequest?.tasks || !tasksRequest?.tasks.length) { + return res.status(200).json({ message: "No tasks to remind" }); + } + + // Group tasks by assigned_to, to avoid sending multiple emails to the same recipient. + const groupedTasks = tasksRequest.tasks.reduce((acc, task) => { + const key = task.assigned_to; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(task); + return acc; + }, {}); + + // No grouped tasks, bail. + if (Object.keys(groupedTasks).length === 0) { + return res.status(200).json({ message: "No tasks to remind" }); + } + + // Build an aggregate data object containing email and the count of tasks assigned to them. + const recipientCounts = Object.keys(groupedTasks).map((key) => { + return { + email: key, + count: groupedTasks[key].length + }; + }); + + // Iterate over all recipients and send the email. + recipientCounts.forEach((recipient) => { + const emailData = { + from: fromEmails, + to: recipient.email + }; + + const taskIds = groupedTasks[recipient.email].map((task) => task.id); + + // There is only the one email to send to this author. + if (recipient.count === 1) { + const onlyTask = groupedTasks[recipient.email][0]; + + emailData.subject = `New Task Reminder - ${onlyTask.title} - ${formatDate(onlyTask.due_date)}`; + + emailData.html = generateEmailTemplate( + generateTemplateArgs(onlyTask.title, onlyTask.created_by, onlyTask.due_date, onlyTask.id) + ); + } + // There are multiple emails to send to this author. + else { + const allTasks = groupedTasks[recipient.email]; + emailData.subject = `New Task Reminder - ${allTasks.length} Tasks require your attention`; + emailData.html = generateEmailTemplate({ + header: `${allTasks.length} Tasks require your attention`, + subHeader: `Please sign in to your account to view the Task details.`, + body: `` + }); + } + + if (emailData?.subject && emailData?.html) { + // Send Email + sendMail("remind", emailData.to, emailData.subject, emailData.html, taskIds, (taskIds) => {}); + client.request(UPDATE_TASKS_REMIND_AT_SENT, { + taskIds, + now: moment().toISOString() + }); + } + }); + + // Sixth step would be to set the remind_at_sent to the current time. + res.status(200).json({ status: "success" }); + } catch (err) { + res.status(500).json({ + status: "error", + message: `Something went wrong sending Task Reminders: ${err.message || "An error occurred"}` + }); + } +}; + +module.exports = { + taskAssignedEmail, + tasksRemindEmail +}; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index f5cbdb3b1..1ffe8c858 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2393,3 +2393,37 @@ exports.COMPLETE_SURVEY = `mutation COMPLETE_SURVEY($surveyId: uuid!, $survey: c affected_rows } }`; + +exports.QUERY_REMIND_TASKS = ` + query QUERY_REMIND_TASKS($time: timestamptz!) { + tasks( + where: { + _and: [ + { remind_at: { _is_null: false } } + { remind_at: { _lte: $time } } + { remind_at_sent: { _is_null: true } } + ] + } + ) { + id + title + due_date + created_by + assigned_to + remind_at + remind_at_sent + priority + job { + id + ro_number + } + jobid + } + } +`; + +exports.UPDATE_TASKS_REMIND_AT_SENT = `mutation UPDATE_TASK_REMIND_AT_SENT($taskIds: [uuid!]!, $now: timestamptz!) { + update_tasks_many(updates: {where: {id: {_in: $taskIds}}, _set: {remind_at_sent: $now}}) { + affected_rows + } +}`; diff --git a/server/routes/miscellaneousRoutes.js b/server/routes/miscellaneousRoutes.js index e51f4a5af..b2dd80b26 100644 --- a/server/routes/miscellaneousRoutes.js +++ b/server/routes/miscellaneousRoutes.js @@ -10,6 +10,7 @@ const os = require("../opensearch/os-handler"); const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware"); +const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails"); //Test route to ensure Express is responding. router.get("/test", async function (req, res) { @@ -41,8 +42,8 @@ router.post("/sendemail", validateFirebaseIdTokenMiddleware, sendEmail.sendEmail router.post("/emailbounce", bodyParser.text(), sendEmail.emailBounce); // Tasks Email Handler -router.post("/tasks-assigned-handler", eventAuthorizationMiddleware, sendEmail.taskAssignedEmail); -router.post("/tasks-remind-handler", eventAuthorizationMiddleware, sendEmail.tasksRemindEmail); +router.post("/tasks-assigned-handler", eventAuthorizationMiddleware, taskAssignedEmail); +router.post("/tasks-remind-handler", eventAuthorizationMiddleware, tasksRemindEmail); // Handlers router.post("/record-handler/arms", data.arms);