feature/IO-2433-esignature - Add in Notifications
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
28
client/src/utils/replaceAccents.js
Normal file
28
client/src/utils/replaceAccents.js
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
268
server/notifications/dispatchJobWatcherNotification.js
Normal file
268
server/notifications/dispatchJobWatcherNotification.js
Normal 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
|
||||
};
|
||||
231
server/notifications/dispatchJobWatcherNotification.test.js
Normal file
231
server/notifications/dispatchJobWatcherNotification.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
136
server/notifications/esignNotifications.js
Normal file
136
server/notifications/esignNotifications.js
Normal 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
|
||||
};
|
||||
30
server/utils/replaceAccents.js
Normal file
30
server/utils/replaceAccents.js
Normal 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;
|
||||
Reference in New Issue
Block a user