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);