Files
bodyshop/server/esign/webhook.js

338 lines
12 KiB
JavaScript

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,
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 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"
};
/**
* 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;
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:
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId,
esig_update: {
status: "OPENED",
opened: true
}
});
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`;
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();
const fileName = document.filename?.toLowerCase().endsWith(".pdf")
? document.filename
: `${document.filename || `esignature-document-${payload.id}`}.pdf`;
const pdfBlob = new Blob([buffer], { type: "application/pdf" });
formData.append("jobid", jobid);
formData.append("file", pdfBlob, fileName);
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 {
throw new Error(
`Local media server upload failed with status ${imexMediaServerResponse.status}: ${imexMediaServerResponse.statusText}`
);
}
} catch (error) {
logger.log(`esig-webhook-lms-upload-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
jobid,
documentId: payload.id
});
notifyUploadFailure();
throw error;
}
} 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 {
const uploadError = new Error(uploadResult.message || "S3 upload failed");
uploadError.stack = uploadResult.stack || uploadError.stack;
throw uploadError;
}
} catch (error) {
logger.log(`esig-webhook-s3-upload-error`, "ERROR", "redis", "api", {
message: error.message,
stack: error.stack,
jobid: jobid,
documentId: payload.id
});
notifyUploadFailure();
throw error;
}
}
//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
});
throw error;
}
}
module.exports = {
esignWebhook
};