feature/IO-2433-esignature - Add in Notifications

This commit is contained in:
Dave
2026-04-30 18:06:32 -04:00
parent 0014a5335d
commit a6156a70c1
12 changed files with 1038 additions and 255 deletions

View File

@@ -4,6 +4,7 @@ import i18n from "i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
import { replaceAccents } from "../../utils/replaceAccents.js";
import client from "../../utils/GraphQLClient";
//Context: currentUserEmail, bodyshop, jobid, invoiceid
@@ -144,32 +145,3 @@ export const uploadToS3 = async (
if (onError) onError(JSON.stringify(error.message));
}
};
function replaceAccents(str) {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
}

View File

@@ -12,7 +12,7 @@ import {
UPDATE_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATIONS_AUTOADD
} from "../../graphql/user.queries.js";
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import { notificationScenarios, notificationScenarioDefaults } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
@@ -55,7 +55,8 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
// Ensure each scenario has an object with { app, email, fcm }
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
acc[scenario] = settings[scenario] ??
notificationScenarioDefaults[scenario] ?? { app: false, email: false, fcm: false };
return acc;
}, {});

View File

@@ -2780,6 +2780,9 @@
"alternate-transport-changed": "Alternate Transport Changed",
"bill-posted": "Bill Posted",
"critical-parts-status-changed": "Critical Parts Status Changed",
"esign-document-completed": "E-Sign Document Completed",
"esign-document-opened": "E-Sign Document Opened",
"esign-document-upload-failed": "E-Sign Document Upload Failed",
"intake-delivery-checklist-completed": "Intake or Delivery Checklist Completed",
"job-added-to-production": "Job Added to Production",
"job-assigned-to-me": "Job Assigned to Me",

View File

@@ -2780,6 +2780,9 @@
"alternate-transport-changed": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"esign-document-completed": "E-Sign Document Completed",
"esign-document-opened": "E-Sign Document Opened",
"esign-document-upload-failed": "E-Sign Document Upload Failed",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-assigned-to-me": "",

View File

@@ -2780,6 +2780,9 @@
"alternate-transport-changed": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"esign-document-completed": "E-Sign Document Completed",
"esign-document-opened": "E-Sign Document Opened",
"esign-document-upload-failed": "E-Sign Document Upload Failed",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-assigned-to-me": "",

View File

@@ -16,8 +16,22 @@ const notificationScenarios = [
"job-added-to-production",
"job-status-change",
"payment-collected-completed",
"alternate-transport-changed"
"alternate-transport-changed",
"esign-document-opened",
"esign-document-completed",
"esign-document-upload-failed"
// "supplement-imported", // Disabled for now
];
export { notificationScenarios };
/**
* Default channel preferences for e-sign document notifications. By default, all e-sign related notifications will be
* sent via the app, but not via email or FCM. These defaults can be overridden by user preferences.
* @type {{"esign-document-opened": {app: boolean, email: boolean, fcm: boolean}, "esign-document-completed": {app: boolean, email: boolean, fcm: boolean}, "esign-document-upload-failed": {app: boolean, email: boolean, fcm: boolean}}}
*/
const notificationScenarioDefaults = {
"esign-document-opened": { app: true, email: false, fcm: false },
"esign-document-completed": { app: true, email: false, fcm: false },
"esign-document-upload-failed": { app: true, email: false, fcm: false }
};
export { notificationScenarios, notificationScenarioDefaults };

View File

@@ -0,0 +1,28 @@
export const replaceAccents = (str) => {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
};

View File

@@ -1,242 +1,336 @@
const { Documenso } = require("@documenso/sdk-typescript");
const logger = require("../utils/logger");
const { QUERY_META_FOR_ESIG_COMPLETION, INSERT_ESIGNATURE_COMPLETED_DOCOUMENT, UPDATE_ESIGNATURE_DOCUMENT, DISTRIBUTE_ESIGNATURE_DOCUMENT, QUERY_DOCUMENSO_KEY, GET_DOCUMENSO_KEY_BY_JOBID } = require("../graphql-client/queries");
const {
QUERY_META_FOR_ESIG_COMPLETION,
INSERT_ESIGNATURE_COMPLETED_DOCOUMENT,
UPDATE_ESIGNATURE_DOCUMENT,
DISTRIBUTE_ESIGNATURE_DOCUMENT,
GET_DOCUMENSO_KEY_BY_JOBID
} = require("../graphql-client/queries");
const replaceAccents = require("../utils/replaceAccents");
const { uploadFileBuffer } = require("../media/imgproxy-media");
const {
dispatchEsignDocumentOpenedNotification,
dispatchEsignDocumentCompletedNotification,
dispatchEsignDocumentUploadFailedNotification
} = require("../notifications/esignNotifications");
const axios = require("axios");
const normalizeUrl = require("normalize-url");
const { log } = require("node-persist");
const client = require('../graphql-client/graphql-client').client;
const client = require("../graphql-client/graphql-client").client;
/**
* Enumeration of webhook event types received from the e-signature service. These events represent different stages in
* the document signing process, such as when a document is created, sent, opened, signed, completed, rejected,
* cancelled, or when a reminder is sent. This enumeration is used to handle incoming webhook events and trigger
* appropriate actions based on the event type.
* @type {{DOCUMENT_CREATED: string, DOCUMENT_SENT: string, DOCUMENT_COMPLETED: string, DOCUMENT_REJECTED: string, DOCUMENT_CANCELLED: string, DOCUMENT_OPENED: string, DOCUMENT_SIGNED: string, DOCUMENT_REMINDER_SENT: string}}
*/
const webhookTypeEnums = {
DOCUMENT_CREATED: "DOCUMENT_CREATED",
DOCUMENT_SENT: "DOCUMENT_SENT",
DOCUMENT_COMPLETED: "DOCUMENT_COMPLETED",
DOCUMENT_REJECTED: "DOCUMENT_REJECTED",
DOCUMENT_CANCELLED: "DOCUMENT_CANCELLED",
DOCUMENT_OPENED: "DOCUMENT_OPENED",
DOCUMENT_SIGNED: "DOCUMENT_SIGNED",
DOCUMENT_REMINDER_SENT: "DOCUMENT_REMINDER_SENT",
DOCUMENT_CREATED: "DOCUMENT_CREATED",
DOCUMENT_SENT: "DOCUMENT_SENT",
DOCUMENT_COMPLETED: "DOCUMENT_COMPLETED",
DOCUMENT_REJECTED: "DOCUMENT_REJECTED",
DOCUMENT_CANCELLED: "DOCUMENT_CANCELLED",
DOCUMENT_OPENED: "DOCUMENT_OPENED",
DOCUMENT_SIGNED: "DOCUMENT_SIGNED",
DOCUMENT_REMINDER_SENT: "DOCUMENT_REMINDER_SENT"
};
/**
* Safely dispatches e-sign notifications by catching and logging any errors that occur during the dispatch process.
* This ensures that failures in notification dispatch do not affect the main flow of processing webhook events.
* The function takes an object containing the promise returned by the notification dispatch function, the event name,
* job ID, and document ID for logging purposes.
* @param param0
* @param param0.promise
* @param param0.eventName
* @param param0.jobid
* @param param0.documentId
*/
function dispatchEsignNotificationSafely({ promise, eventName, jobid, documentId }) {
promise.catch((error) => {
logger.log("esig-notification-dispatch-error", "ERROR", "notifications", "api", {
eventName,
jobid,
documentId,
message: error.message,
stack: error.stack
});
});
}
/**
* Handles incoming webhook events from the e-signature service. It processes different event types such as document
* opened, completed, rejected, etc., updates the document status in the database accordingly, and dispatches
* notifications to users. The function also includes error handling to log any issues that occur during processing and
* ensure a proper response is sent back to the webhook sender.
* @param req
* @param res
* @returns {Promise<void>}
*/
async function esignWebhook(req, res) {
try {
const message = req.body
logger.log(`esig-webhook-received`, "DEBUG", "redis", "api", {
event: message.event,
body: message
});
try {
const message = req.body;
const documentPayload = message.payload || message.payload?.payload || {};
const externalId = documentPayload.externalId || documentPayload.payload?.externalId || "";
const [jobid, uploadedBy] = externalId.split("|");
logger.log(`esig-webhook-received`, "DEBUG", "redis", "api", {
event: message.event,
body: message
});
const documentId = (message.payload?.id || message.payload?.payload?.id)?.toString()
//TODO: Implement checks to prevent this from going backwards in status? If a request fails, it retries, which could cause a document marked as completed to be marked as rejected if the rejection event is processed after the completion event.
switch (message.event) {
case webhookTypeEnums.DOCUMENT_REMINDER_SENT:
break;
case webhookTypeEnums.DOCUMENT_OPENED:
//TODO: DR: Add notification for document opened.
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "OPENED",
opened: true,
}
})
break;
case webhookTypeEnums.DOCUMENT_REJECTED:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "REJECTED",
rejected: true,
}
})
break;
case webhookTypeEnums.DOCUMENT_CREATED:
//This is largely a throwaway event we know it was created.
break;
case webhookTypeEnums.DOCUMENT_COMPLETED:
//TODO: DR: Add notification for document completed.
await handleDocumentCompleted(message.payload);
break;
case webhookTypeEnums.DOCUMENT_SIGNED:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "SIGNED",
}
})
break;
default:
res.status(200).json({ message: "Unsupported event type." });
logger.log(`esig-webhook-received-unknown`, "ERROR", "redis", "api", {
event: message.event,
body: message
});
return;
}
logger.log(`esig-webhook-processed`, "INFO", "redis", "api", { event: message.event, documentId: message.payload?.payload?.id, jobid: message.payload?.payload?.externalId?.split("|")[0] || null });
res.sendStatus(200)
} catch (error) {
logger.log(`esig-webhook-error`, "ERROR", "redis", "api", {
message: error.message, stack: error.stack,
body: req.body
const documentId = (message.payload?.id || message.payload?.payload?.id)?.toString();
//TODO: Implement checks to prevent this from going backwards in status? If a request fails, it retries, which could cause a document marked as completed to be marked as rejected if the rejection event is processed after the completion event.
switch (message.event) {
case webhookTypeEnums.DOCUMENT_REMINDER_SENT:
break;
case webhookTypeEnums.DOCUMENT_OPENED:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "OPENED",
opened: true
}
});
res.status(500).json({ message: "Error processing webhook event.", error: error.message });
dispatchEsignNotificationSafely({
promise: dispatchEsignDocumentOpenedNotification({
jobId: jobid,
documentId,
title: documentPayload.title,
uploadedBy,
logger
}),
eventName: webhookTypeEnums.DOCUMENT_OPENED,
jobid,
documentId
});
break;
case webhookTypeEnums.DOCUMENT_REJECTED:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "REJECTED",
rejected: true
}
});
break;
case webhookTypeEnums.DOCUMENT_CREATED:
//This is largely a throwaway event we know it was created.
break;
case webhookTypeEnums.DOCUMENT_COMPLETED:
await handleDocumentCompleted(message.payload);
break;
case webhookTypeEnums.DOCUMENT_SIGNED:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "SIGNED"
}
});
break;
default:
res.status(200).json({ message: "Unsupported event type." });
logger.log(`esig-webhook-received-unknown`, "ERROR", "redis", "api", {
event: message.event,
body: message
});
return;
}
logger.log(`esig-webhook-processed`, "INFO", "redis", "api", {
event: message.event,
documentId: message.payload?.payload?.id,
jobid: message.payload?.payload?.externalId?.split("|")[0] || null
});
res.sendStatus(200);
} catch (error) {
logger.log(`esig-webhook-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
body: req.body
});
res.status(500).json({ message: "Error processing webhook event.", error: error.message });
}
}
/**
* Handles the processing of a document completion event. This includes downloading the completed document from the
* e-signature service, uploading it to either a local media server or S3 depending on the bodyshop configuration,
* updating the document record in the database, and dispatching a notification about the document completion.
* The function also includes error handling to log any issues that occur during processing.
* @param payload
* @returns {Promise<void>}
*/
async function handleDocumentCompleted(payload) {
try {
//Split the external id to get the uploaded user.
const [jobid, uploaded_by] = payload.externalId.split("|");
if (!jobid || !uploaded_by) {
throw new Error(`Invalid externalId format. Expected "jobid|uploaded_by", got "${payload.externalId}"`);
}
const { jobs_by_pk } = await client.request(QUERY_META_FOR_ESIG_COMPLETION, {
jobid
});
//Have to use globally authed cleint since this a webhook.
const { jobs_by_pk: { bodyshop: { documenso_api_key } } } = await client.request(GET_DOCUMENSO_KEY_BY_JOBID, {
jobid,
})
const documenso = new Documenso({
apiKey: documenso_api_key,
serverURL: "https://sign.imex.online/api/v2",
});
const document = await documenso.document.documentDownload({
documentId: payload.id,
});
const response = await fetch(document.downloadUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let key = `${jobs_by_pk.bodyshop.id}/${jobs_by_pk.id}/${replaceAccents(document.filename).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.pdf`;
if (jobs_by_pk?.bodyshop?.uselocalmediaserver) {
const { bodyshop: { localmediaserverhttp, localmediatoken } } = jobs_by_pk;
const options = {
headers: {
"X-Requested-With": "XMLHttpRequest",
ims_token: localmediatoken
},
};
const formData = new FormData();
formData.append("jobid", jobid);
formData.append("file", buffer); //TODO: Validate this is the correct type.
const imexMediaServerResponse = await axios.post(
normalizeUrl(`${localmediaserverhttp}/jobs/upload`),
formData,
options
);
if (imexMediaServerResponse.status !== 200) {
log.error(`esig-webhook-lms-upload-error`, "ERROR", "redis", "api", {
message: imexMediaServerResponse.statusText,
jobid,
documentId: payload.id
});
//TODO: DR: Add notification for document upload failure.
} else {
//Succesful upload - we don't really need to do anything here.
}
} else {
//S3 Upload
const uploadResult = await uploadFileBuffer({ key, buffer, contentType: "application/pdf" });
if (!uploadResult.success) {
logger.log(`esig-webhook-s3-upload-error`, "ERROR", "redis", "api", {
message: uploadResult.message,
stack: uploadResult.stack,
jobid: jobid,
documentId: payload.id
});
} else {
logger.log(`esig-webhook-s3-upload-success`, "INFO", "redis", "api", {
jobid: jobid,
documentId: payload.id,
s3Key: key,
bucket: uploadResult.bucket
});
//insert the document record with the s3 key and bucket info.
await client.request(INSERT_ESIGNATURE_COMPLETED_DOCOUMENT, {
docInput: {
jobid: jobs_by_pk.id,
uploaded_by: uploaded_by,
key,
type: "application/pdf",
extension: "pdf",
bodyshopid: jobs_by_pk.bodyshop.id,
size: buffer.length,
takenat: new Date().toISOString(),
}
})
}
}
//Update the audit trail and records to mark the document as completed.
await client.request(DISTRIBUTE_ESIGNATURE_DOCUMENT, {
external_document_id: payload.id.toString(),
esig_update: {
status: "COMPLETED",
completed: true,
completed_at: new Date().toISOString()
},
audit: {
jobid: jobs_by_pk.id,
bodyshopid: jobs_by_pk.bodyshop.id,
operation: `Esignature document with title ${payload.title} (ID: ${payload.documentMeta.id}) has been completed.`,
useremail: uploaded_by,
type: 'esig-complete'
}
})
} catch (error) {
logger.log(`esig-webhook-event-completed-error`, "ERROR", "redis", "api", {
message: error.message, stack: error.stack,
payload
});
try {
//Split the external id to get the uploaded user.
const [jobid, uploaded_by] = payload.externalId.split("|");
if (!jobid || !uploaded_by) {
throw new Error(`Invalid externalId format. Expected "jobid|uploaded_by", got "${payload.externalId}"`);
}
}
const { jobs_by_pk } = await client.request(QUERY_META_FOR_ESIG_COMPLETION, {
jobid
});
//Have to use globally authed cleint since this a webhook.
const {
jobs_by_pk: {
bodyshop: { documenso_api_key }
}
} = await client.request(GET_DOCUMENSO_KEY_BY_JOBID, {
jobid
});
const documenso = new Documenso({
apiKey: documenso_api_key,
serverURL: "https://sign.imex.online/api/v2"
});
const document = await documenso.document.documentDownload({
documentId: payload.id
});
const response = await fetch(document.downloadUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let key = `${jobs_by_pk.bodyshop.id}/${jobs_by_pk.id}/${replaceAccents(document.filename).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.pdf`;
const notifyUploadFailure = () =>
dispatchEsignNotificationSafely({
promise: dispatchEsignDocumentUploadFailedNotification({
jobId: jobs_by_pk.id,
documentId: payload.id.toString(),
title: payload.title,
uploadedBy: uploaded_by,
logger
}),
eventName: "DOCUMENT_UPLOAD_FAILED",
jobid,
documentId: payload.id.toString()
});
if (jobs_by_pk?.bodyshop?.uselocalmediaserver) {
const {
bodyshop: { localmediaserverhttp, localmediatoken }
} = jobs_by_pk;
const options = {
headers: {
"X-Requested-With": "XMLHttpRequest",
ims_token: localmediatoken
}
};
const formData = new FormData();
formData.append("jobid", jobid);
formData.append("file", buffer); //TODO: Validate this is the correct type.
try {
const imexMediaServerResponse = await axios.post(
normalizeUrl(`${localmediaserverhttp}/jobs/upload`),
formData,
options
);
if (imexMediaServerResponse.status === 200) {
//Succesful upload - we don't really need to do anything here.
} else {
logger.log(`esig-webhook-lms-upload-error`, "ERROR", "redis", "api", {
message: imexMediaServerResponse.statusText,
jobid,
documentId: payload.id
});
notifyUploadFailure();
}
} catch (error) {
logger.log(`esig-webhook-lms-upload-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
jobid,
documentId: payload.id
});
notifyUploadFailure();
}
} else {
try {
const uploadResult = await uploadFileBuffer({ key, buffer, contentType: "application/pdf" });
if (uploadResult.success) {
logger.log(`esig-webhook-s3-upload-success`, "INFO", "redis", "api", {
jobid: jobid,
documentId: payload.id,
s3Key: key,
bucket: uploadResult.bucket
});
//insert the document record with the s3 key and bucket info.
await client.request(INSERT_ESIGNATURE_COMPLETED_DOCOUMENT, {
docInput: {
jobid: jobs_by_pk.id,
uploaded_by: uploaded_by,
key,
type: "application/pdf",
extension: "pdf",
bodyshopid: jobs_by_pk.bodyshop.id,
size: buffer.length,
takenat: new Date().toISOString()
}
});
} else {
logger.log(`esig-webhook-s3-upload-error`, "ERROR", "redis", "api", {
message: uploadResult.message,
stack: uploadResult.stack,
jobid: jobid,
documentId: payload.id
});
notifyUploadFailure();
}
} catch (error) {
logger.log(`esig-webhook-s3-upload-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
jobid: jobid,
documentId: payload.id
});
notifyUploadFailure();
}
}
//Update the audit trail and records to mark the document as completed.
await client.request(DISTRIBUTE_ESIGNATURE_DOCUMENT, {
external_document_id: payload.id.toString(),
esig_update: {
status: "COMPLETED",
completed: true,
completed_at: new Date().toISOString()
},
audit: {
jobid: jobs_by_pk.id,
bodyshopid: jobs_by_pk.bodyshop.id,
operation: `Esignature document with title ${payload.title} (ID: ${payload.documentMeta.id}) has been completed.`,
useremail: uploaded_by,
type: "esig-complete"
}
});
dispatchEsignNotificationSafely({
promise: dispatchEsignDocumentCompletedNotification({
jobId: jobs_by_pk.id,
documentId: payload.id.toString(),
title: payload.title,
uploadedBy: uploaded_by,
logger
}),
eventName: webhookTypeEnums.DOCUMENT_COMPLETED,
jobid,
documentId: payload.id.toString()
});
} catch (error) {
logger.log(`esig-webhook-event-completed-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
payload
});
}
}
module.exports = {
esignWebhook
}
function replaceAccents(str) {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
}
esignWebhook
};

View File

@@ -0,0 +1,268 @@
const { client: gqlClient } = require("../graphql-client/graphql-client");
const { GET_JOB_WATCHERS, GET_NOTIFICATION_ASSOCIATIONS } = require("../graphql-client/queries");
const { dispatchEmailsToQueue } = require("./queues/emailQueue");
const { dispatchAppsToQueue } = require("./queues/appQueue");
const { dispatchFcmsToQueue } = require("./queues/fcmQueue");
/**
* Default channel preferences to fall back on if a recipient doesn't have specific preferences set for a scenario.
* @type {Readonly<{app: boolean, email: boolean, fcm: boolean}>}
*/
const DEFAULT_CHANNEL_PREFERENCES = Object.freeze({
app: false,
email: false,
fcm: false
});
/**
* Normalizes channel preferences for a recipient based on their specific preferences for a scenario, falling back to
* default preferences if not set.
* @param preferences
* @param fallbackPreferences
* @returns {{app, email, fcm}}
*/
const normalizeChannelPreferences = (preferences, fallbackPreferences = DEFAULT_CHANNEL_PREFERENCES) => ({
app: preferences?.app ?? fallbackPreferences.app ?? false,
email: preferences?.email ?? fallbackPreferences.email ?? false,
fcm: preferences?.fcm ?? fallbackPreferences.fcm ?? false
});
/**
* Builds notification payloads for app, email, and FCM channels based on the provided parameters and recipient
* preferences.
* @param param0
* @param param0.jobId
* @param param0.jobRoNumber
* @param param0.bodyShopId
* @param param0.bodyShopName
* @param param0.bodyShopTimezone
* @param param0.scenarioKey
* @param param0.scenarioTable
* @param param0.key
* @param param0.body
* @param param0.variables
* @param param0.recipients
* @returns {{app: {jobId: *, jobRoNumber: *, bodyShopId: *, scenarioKey: *, scenarioTable: *, key: *, body: *, variables: *, recipients: *}, email: {jobId: *, jobRoNumber: *, bodyShopName: *, bodyShopTimezone: *, body: *, recipients: *}, fcm: {jobId: *, jobRoNumber: *, bodyShopId: *, bodyShopName: *, bodyShopTimezone: *, scenarioKey: *, scenarioTable: *, key: *, body: *, variables: *, recipients: *}}}
*/
const buildNotificationPayloads = ({
jobId,
jobRoNumber,
bodyShopId,
bodyShopName,
bodyShopTimezone,
scenarioKey,
scenarioTable,
key,
body,
variables,
recipients
}) => ({
app: {
jobId,
jobRoNumber,
bodyShopId,
scenarioKey,
scenarioTable,
key,
body,
variables,
recipients: recipients
.filter((recipient) => recipient.app)
.map(({ user, employeeId, associationId }) => ({
user,
bodyShopId,
employeeId,
associationId
}))
},
email: {
jobId,
jobRoNumber,
bodyShopName,
bodyShopTimezone,
body,
recipients: recipients
.filter((recipient) => recipient.email)
.map(({ user, firstName, lastName }) => ({ user, firstName, lastName }))
},
fcm: {
jobId,
jobRoNumber,
bodyShopId,
bodyShopName,
bodyShopTimezone,
scenarioKey,
scenarioTable,
key,
body,
variables,
recipients: recipients
.filter((recipient) => recipient.fcm)
.map(({ user, employeeId, associationId }) => ({
user,
bodyShopId,
employeeId,
associationId
}))
}
});
/**
* Dispatches notifications to job watchers based on their preferences for a given scenario. It retrieves the watchers
* of a job, determines their notification preferences, builds the appropriate payloads for each channel, and dispatches
* the notifications to the respective queues.
* @param param0
* @param param0.jobId
* @param param0.scenarioKey
* @param param0.key
* @param param0.body
* @param param0.variables
* @param param0.scenarioTable
* @param param0.extraRecipientEmails
* @param param0.defaultChannelPreferences
* @param param0.logger
* @returns {Promise<boolean>}
*/
async function dispatchJobWatcherNotification({
jobId,
scenarioKey,
key,
body,
variables = {},
scenarioTable = "esignature_documents",
extraRecipientEmails = [],
defaultChannelPreferences = DEFAULT_CHANNEL_PREFERENCES,
logger
}) {
if (!jobId || !scenarioKey || !key || !body) {
return false;
}
const watcherData = await gqlClient.request(GET_JOB_WATCHERS, { jobid: jobId });
const bodyShopId = watcherData?.job?.bodyshop?.id;
const bodyShopName = watcherData?.job?.bodyshop?.shopname;
const bodyShopTimezone = watcherData?.job?.bodyshop?.timezone;
const jobRoNumber = watcherData?.job?.ro_number;
if (!bodyShopId || !bodyShopName) {
logger?.log?.("dispatch-job-watcher-notification-missing-job-meta", "WARN", "notifications", "api", {
jobId,
scenarioKey
});
return false;
}
const recipientsByEmail = new Map();
for (const watcher of watcherData?.job_watchers || []) {
if (!watcher?.user_email) continue;
recipientsByEmail.set(watcher.user_email, {
email: watcher.user_email,
firstName: watcher?.user?.employee?.first_name || null,
lastName: watcher?.user?.employee?.last_name || null,
employeeId: watcher?.user?.employee?.id || null
});
}
for (const recipientEmail of extraRecipientEmails) {
if (!recipientEmail || recipientsByEmail.has(recipientEmail)) continue;
recipientsByEmail.set(recipientEmail, {
email: recipientEmail,
firstName: null,
lastName: null,
employeeId: null
});
}
const recipientEmails = [...recipientsByEmail.keys()];
if (!recipientEmails.length) {
logger?.log?.("dispatch-job-watcher-notification-no-recipients", "INFO", "notifications", "api", {
jobId,
scenarioKey
});
return false;
}
const associationsData = await gqlClient.request(GET_NOTIFICATION_ASSOCIATIONS, {
emails: recipientEmails,
shopid: bodyShopId
});
const eligibleRecipients = (associationsData?.associations || [])
.map((association) => {
const preferences = normalizeChannelPreferences(
association?.notification_settings?.[scenarioKey],
defaultChannelPreferences
);
if (!preferences.app && !preferences.email && !preferences.fcm) {
return null;
}
const watcher = recipientsByEmail.get(association.useremail);
return {
user: association.useremail,
app: preferences.app,
email: preferences.email,
fcm: preferences.fcm,
firstName: watcher?.firstName || null,
lastName: watcher?.lastName || null,
employeeId: watcher?.employeeId || null,
associationId: association.id
};
})
.filter(Boolean);
if (!eligibleRecipients.length) {
logger?.log?.("dispatch-job-watcher-notification-no-eligible-recipients", "INFO", "notifications", "api", {
jobId,
scenarioKey,
bodyShopId
});
return false;
}
const payloads = buildNotificationPayloads({
jobId,
jobRoNumber,
bodyShopId,
bodyShopName,
bodyShopTimezone,
scenarioKey,
scenarioTable,
key,
body,
variables,
recipients: eligibleRecipients
});
const dispatches = [];
if (payloads.email.recipients.length) {
dispatches.push(dispatchEmailsToQueue({ emailsToDispatch: [payloads.email], logger }));
}
if (payloads.app.recipients.length) {
dispatches.push(dispatchAppsToQueue({ appsToDispatch: [payloads.app], logger }));
}
if (payloads.fcm.recipients.length) {
dispatches.push(dispatchFcmsToQueue({ fcmsToDispatch: [payloads.fcm], logger }));
}
if (!dispatches.length) {
return false;
}
await Promise.all(dispatches);
return true;
}
module.exports = {
DEFAULT_CHANNEL_PREFERENCES,
dispatchJobWatcherNotification
};

View File

@@ -0,0 +1,231 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const graphClientModuleId = require.resolve("../graphql-client/graphql-client");
const queriesModuleId = require.resolve("../graphql-client/queries");
const emailQueueModuleId = require.resolve("./queues/emailQueue");
const appQueueModuleId = require.resolve("./queues/appQueue");
const fcmQueueModuleId = require.resolve("./queues/fcmQueue");
const dispatcherModuleId = require.resolve("./dispatchJobWatcherNotification");
const dispatchEmailsToQueueMock = vi.fn();
const dispatchAppsToQueueMock = vi.fn();
const dispatchFcmsToQueueMock = vi.fn();
const loadDispatcher = ({ requestMock }) => {
mock.stopAll();
dispatchEmailsToQueueMock.mockReset();
dispatchAppsToQueueMock.mockReset();
dispatchFcmsToQueueMock.mockReset();
mock(graphClientModuleId, { client: { request: requestMock } });
mock(queriesModuleId, {
GET_JOB_WATCHERS: "GET_JOB_WATCHERS",
GET_NOTIFICATION_ASSOCIATIONS: "GET_NOTIFICATION_ASSOCIATIONS"
});
mock(emailQueueModuleId, { dispatchEmailsToQueue: dispatchEmailsToQueueMock });
mock(appQueueModuleId, { dispatchAppsToQueue: dispatchAppsToQueueMock });
mock(fcmQueueModuleId, { dispatchFcmsToQueue: dispatchFcmsToQueueMock });
delete require.cache[dispatcherModuleId];
return require(dispatcherModuleId);
};
afterEach(() => {
mock.stopAll();
delete require.cache[dispatcherModuleId];
});
describe("dispatchJobWatcherNotification", () => {
it("dispatches queue payloads using watcher settings plus fallback defaults for extra recipients", async () => {
const requestMock = vi.fn(async (query) => {
if (query === "GET_JOB_WATCHERS") {
return {
job_watchers: [
{
user_email: "watcher@example.com",
user: {
employee: {
id: "emp-1",
first_name: "Pat",
last_name: "Lee"
}
}
}
],
job: {
ro_number: "RO-123",
bodyshop: {
id: "shop-1",
shopname: "ImEX",
timezone: "America/Toronto"
}
}
};
}
if (query === "GET_NOTIFICATION_ASSOCIATIONS") {
return {
associations: [
{
id: "assoc-1",
useremail: "watcher@example.com",
notification_settings: {
"esign-document-opened": {
app: true,
email: false,
fcm: true
}
}
},
{
id: "assoc-2",
useremail: "creator@example.com",
notification_settings: {}
}
]
};
}
return {};
});
const { dispatchJobWatcherNotification } = loadDispatcher({ requestMock });
const logger = { log: vi.fn() };
const result = await dispatchJobWatcherNotification({
jobId: "job-1",
scenarioKey: "esign-document-opened",
key: "notifications.job.esignDocumentOpened",
body: '"Repair Authorization" has been opened.',
variables: { documentId: "123" },
extraRecipientEmails: ["creator@example.com"],
defaultChannelPreferences: {
app: true,
email: false,
fcm: false
},
logger
});
expect(result).toBe(true);
expect(requestMock).toHaveBeenCalledTimes(2);
expect(dispatchEmailsToQueueMock).not.toHaveBeenCalled();
expect(dispatchAppsToQueueMock).toHaveBeenCalledTimes(1);
expect(dispatchAppsToQueueMock.mock.calls[0][0]).toEqual({
appsToDispatch: [
expect.objectContaining({
jobId: "job-1",
jobRoNumber: "RO-123",
bodyShopId: "shop-1",
scenarioKey: "esign-document-opened",
key: "notifications.job.esignDocumentOpened",
recipients: [
{
user: "watcher@example.com",
bodyShopId: "shop-1",
employeeId: "emp-1",
associationId: "assoc-1"
},
{
user: "creator@example.com",
bodyShopId: "shop-1",
employeeId: null,
associationId: "assoc-2"
}
]
})
],
logger
});
expect(dispatchFcmsToQueueMock).toHaveBeenCalledTimes(1);
expect(dispatchFcmsToQueueMock.mock.calls[0][0]).toEqual({
fcmsToDispatch: [
expect.objectContaining({
jobId: "job-1",
scenarioKey: "esign-document-opened",
recipients: [
{
user: "watcher@example.com",
bodyShopId: "shop-1",
employeeId: "emp-1",
associationId: "assoc-1"
}
]
})
],
logger
});
});
it("returns false when no recipients have any enabled channels", async () => {
const requestMock = vi.fn(async (query) => {
if (query === "GET_JOB_WATCHERS") {
return {
job_watchers: [
{
user_email: "watcher@example.com",
user: {
employee: {
id: "emp-1",
first_name: "Pat",
last_name: "Lee"
}
}
}
],
job: {
ro_number: "RO-123",
bodyshop: {
id: "shop-1",
shopname: "ImEX",
timezone: "America/Toronto"
}
}
};
}
if (query === "GET_NOTIFICATION_ASSOCIATIONS") {
return {
associations: [
{
id: "assoc-1",
useremail: "watcher@example.com",
notification_settings: {
"esign-document-opened": {
app: false,
email: false,
fcm: false
}
}
}
]
};
}
return {};
});
const { dispatchJobWatcherNotification } = loadDispatcher({ requestMock });
const result = await dispatchJobWatcherNotification({
jobId: "job-1",
scenarioKey: "esign-document-opened",
key: "notifications.job.esignDocumentOpened",
body: '"Repair Authorization" has been opened.',
logger: { log: vi.fn() }
});
expect(result).toBe(false);
expect(dispatchEmailsToQueueMock).not.toHaveBeenCalled();
expect(dispatchAppsToQueueMock).not.toHaveBeenCalled();
expect(dispatchFcmsToQueueMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,136 @@
const { dispatchJobWatcherNotification } = require("./dispatchJobWatcherNotification");
/**
* Default notification channel preferences for e-sign document events. By default, users will receive in-app
* notifications for e-sign events, but not email or FCM notifications. These defaults can be overridden by user
* preferences or specific notification dispatch calls.
* @type {Readonly<{app: boolean, email: boolean, fcm: boolean}>}
*/
const DEFAULT_ESIGN_CHANNEL_PREFERENCES = Object.freeze({
app: true,
email: false,
fcm: false
});
/**
* Notification scenarios for e-sign document events. Each scenario includes a unique scenario key and a localization
* key for the notification message.
* @type {Readonly<{documentOpened: {scenarioKey: string, key: string}, documentCompleted: {scenarioKey: string, key: string}, documentUploadFailed: {scenarioKey: string, key: string}}>}
*/
const ESIGN_NOTIFICATION_SCENARIOS = Object.freeze({
documentOpened: {
scenarioKey: "esign-document-opened",
key: "notifications.job.esignDocumentOpened"
},
documentCompleted: {
scenarioKey: "esign-document-completed",
key: "notifications.job.esignDocumentCompleted"
},
documentUploadFailed: {
scenarioKey: "esign-document-upload-failed",
key: "notifications.job.esignDocumentUploadFailed"
}
});
/**
* Formats the document title for use in notification messages. If a title is provided, it will be wrapped in quotes;
* if not, a generic description will be used.
* @param title
* @returns {string|string}
*/
const formatDocumentTitle = (title) => (title ? `"${title}"` : "An e-sign document");
/**
* Dispatches a notification when an e-sign document is opened. The notification will include the document title and
* will be sent to the user who uploaded the document (if available) with default channel preferences for e-sign events.
* @param param0
* @param param0.jobId
* @param param0.documentId
* @param param0.title
* @param param0.uploadedBy
* @param param0.logger
* @returns {Promise<boolean>}
*/
async function dispatchEsignDocumentOpenedNotification({ jobId, documentId, title, uploadedBy, logger }) {
return dispatchJobWatcherNotification({
jobId,
scenarioKey: ESIGN_NOTIFICATION_SCENARIOS.documentOpened.scenarioKey,
key: ESIGN_NOTIFICATION_SCENARIOS.documentOpened.key,
body: `${formatDocumentTitle(title)} has been opened.`,
variables: {
documentId,
title: title || null,
uploadedBy: uploadedBy || null,
status: "OPENED"
},
extraRecipientEmails: uploadedBy ? [uploadedBy] : [],
defaultChannelPreferences: DEFAULT_ESIGN_CHANNEL_PREFERENCES,
logger
});
}
/**
* Dispatches a notification when an e-sign document is completed. The notification will include the document title and
* will be sent to the user who uploaded the document (if available) with default channel preferences for e-sign events.
* @param param0
* @param param0.jobId
* @param param0.documentId
* @param param0.title
* @param param0.uploadedBy
* @param param0.logger
* @returns {Promise<boolean>}
*/
async function dispatchEsignDocumentCompletedNotification({ jobId, documentId, title, uploadedBy, logger }) {
return dispatchJobWatcherNotification({
jobId,
scenarioKey: ESIGN_NOTIFICATION_SCENARIOS.documentCompleted.scenarioKey,
key: ESIGN_NOTIFICATION_SCENARIOS.documentCompleted.key,
body: `${formatDocumentTitle(title)} has been completed.`,
variables: {
documentId,
title: title || null,
uploadedBy: uploadedBy || null,
status: "COMPLETED"
},
extraRecipientEmails: uploadedBy ? [uploadedBy] : [],
defaultChannelPreferences: DEFAULT_ESIGN_CHANNEL_PREFERENCES,
logger
});
}
/**
* Dispatches a notification when an e-sign document upload fails. The notification will include the document title and
* will be sent to the user who uploaded the document (if available) with default channel preferences for e-sign events.
* @param param0
* @param param0.jobId
* @param param0.documentId
* @param param0.title
* @param param0.uploadedBy
* @param param0.logger
* @returns {Promise<boolean>}
*/
async function dispatchEsignDocumentUploadFailedNotification({ jobId, documentId, title, uploadedBy, logger }) {
return dispatchJobWatcherNotification({
jobId,
scenarioKey: ESIGN_NOTIFICATION_SCENARIOS.documentUploadFailed.scenarioKey,
key: ESIGN_NOTIFICATION_SCENARIOS.documentUploadFailed.key,
body: `${formatDocumentTitle(title)} was completed, but the signed PDF failed to upload to the job documents.`,
variables: {
documentId,
title: title || null,
uploadedBy: uploadedBy || null,
status: "UPLOAD_FAILED"
},
extraRecipientEmails: uploadedBy ? [uploadedBy] : [],
defaultChannelPreferences: DEFAULT_ESIGN_CHANNEL_PREFERENCES,
logger
});
}
module.exports = {
DEFAULT_ESIGN_CHANNEL_PREFERENCES,
ESIGN_NOTIFICATION_SCENARIOS,
dispatchEsignDocumentOpenedNotification,
dispatchEsignDocumentCompletedNotification,
dispatchEsignDocumentUploadFailedNotification
};

View File

@@ -0,0 +1,30 @@
const replaceAccents = (str) => {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
};
module.exports = replaceAccents;