From a6156a70c10965c8384b1ce6a69c18218b1ee7a9 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 30 Apr 2026 18:06:32 -0400 Subject: [PATCH] feature/IO-2433-esignature - Add in Notifications --- .../documents-upload-imgproxy.utility.js | 30 +- .../notification-settings-form.component.jsx | 5 +- client/src/translations/en_us/common.json | 3 + client/src/translations/es/common.json | 3 + client/src/translations/fr/common.json | 3 + client/src/utils/jobNotificationScenarios.js | 18 +- client/src/utils/replaceAccents.js | 28 + server/esign/webhook.js | 538 ++++++++++-------- .../dispatchJobWatcherNotification.js | 268 +++++++++ .../dispatchJobWatcherNotification.test.js | 231 ++++++++ server/notifications/esignNotifications.js | 136 +++++ server/utils/replaceAccents.js | 30 + 12 files changed, 1038 insertions(+), 255 deletions(-) create mode 100644 client/src/utils/replaceAccents.js create mode 100644 server/notifications/dispatchJobWatcherNotification.js create mode 100644 server/notifications/dispatchJobWatcherNotification.test.js create mode 100644 server/notifications/esignNotifications.js create mode 100644 server/utils/replaceAccents.js diff --git a/client/src/components/documents-upload-imgproxy/documents-upload-imgproxy.utility.js b/client/src/components/documents-upload-imgproxy/documents-upload-imgproxy.utility.js index eb53b53fa..17fa5b548 100644 --- a/client/src/components/documents-upload-imgproxy/documents-upload-imgproxy.utility.js +++ b/client/src/components/documents-upload-imgproxy/documents-upload-imgproxy.utility.js @@ -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; -} diff --git a/client/src/components/notification-settings/notification-settings-form.component.jsx b/client/src/components/notification-settings/notification-settings-form.component.jsx index 3bfb571c3..82ea4ad6c 100644 --- a/client/src/components/notification-settings/notification-settings-form.component.jsx +++ b/client/src/components/notification-settings/notification-settings-form.component.jsx @@ -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; }, {}); diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index f1e34aacb..e2ffc35e3 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -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", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index b54798893..9c93c14c5 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -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": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 7a561ab31..6c54cd906 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -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": "", diff --git a/client/src/utils/jobNotificationScenarios.js b/client/src/utils/jobNotificationScenarios.js index aeab82861..a2839b84e 100644 --- a/client/src/utils/jobNotificationScenarios.js +++ b/client/src/utils/jobNotificationScenarios.js @@ -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 }; diff --git a/client/src/utils/replaceAccents.js b/client/src/utils/replaceAccents.js new file mode 100644 index 000000000..a7a2e5aeb --- /dev/null +++ b/client/src/utils/replaceAccents.js @@ -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; +}; diff --git a/server/esign/webhook.js b/server/esign/webhook.js index 37e01d28c..890d8c55f 100644 --- a/server/esign/webhook.js +++ b/server/esign/webhook.js @@ -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} + */ 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} + */ 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; -} \ No newline at end of file + esignWebhook +}; diff --git a/server/notifications/dispatchJobWatcherNotification.js b/server/notifications/dispatchJobWatcherNotification.js new file mode 100644 index 000000000..dec333522 --- /dev/null +++ b/server/notifications/dispatchJobWatcherNotification.js @@ -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} + */ +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 +}; diff --git a/server/notifications/dispatchJobWatcherNotification.test.js b/server/notifications/dispatchJobWatcherNotification.test.js new file mode 100644 index 000000000..fd57c144f --- /dev/null +++ b/server/notifications/dispatchJobWatcherNotification.test.js @@ -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(); + }); +}); diff --git a/server/notifications/esignNotifications.js b/server/notifications/esignNotifications.js new file mode 100644 index 000000000..c3d849d60 --- /dev/null +++ b/server/notifications/esignNotifications.js @@ -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} + */ +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} + */ +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} + */ +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 +}; diff --git a/server/utils/replaceAccents.js b/server/utils/replaceAccents.js new file mode 100644 index 000000000..d49f2a265 --- /dev/null +++ b/server/utils/replaceAccents.js @@ -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;