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} */ 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} */ 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 };