Files
bodyshop/server/esign/esign-new.js
2026-05-25 16:42:23 -04:00

527 lines
16 KiB
JavaScript

const { Documenso } = require("@documenso/sdk-typescript");
const axios = require("axios");
const { jsrAuthString } = require("../utils/utils");
const logger = require("../utils/logger");
//Need to pull the key dynamically to send documents.
const JSR_SERVER = process.env.JSR_URL || "https://reports.imex.online";
const DOCUMENSO_SERVER_URL = process.env.DOCUMENSO_SERVER_URL || "https://sign.imex.online/api/v2";
const jsreport = require("@jsreport/nodejs-client");
const {
QUERY_JOB_FOR_SIGNATURE,
INSERT_ESIGNATURE_DOCUMENT,
DISTRIBUTE_ESIGNATURE_DOCUMENT,
QUERY_ESIGNATURE_BY_EXTERNAL_ID,
UPDATE_ESIGNATURE_DOCUMENT,
QUERY_DOCUMENSO_KEY,
INSERT_ESIG_AUDIT_TRAIL
} = require("../graphql-client/queries");
const _ = require("lodash");
function parseJsonField(value, fallback = null) {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value !== "string") {
return value;
}
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function getDefaultEsignData({ esigData, bodyshop, fileName }) {
const fallbackTitle = fileName || `Esign request from ${bodyshop.shopname}`;
return {
...esigData,
title: esigData?.title || fallbackTitle,
subject: esigData?.subject || `Esign request from ${bodyshop.shopname}`,
message: esigData?.message || `Please review and sign the document from ${bodyshop.shopname}.`
};
}
function createClientError(message, statusCode = 400) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function getJobOwnerName(jobData, email) {
const ownerName = [jobData?.ownr_fn, jobData?.ownr_ln].filter(Boolean).join(" ").trim();
return ownerName || jobData?.ownr_co_nm || email;
}
function getJobOwnerRecipients(jobData) {
const ownerEmail = jobData?.ownr_ea?.trim();
if (!ownerEmail) {
throw createClientError("Job owner email is required before sending an e-signature request.");
}
if (!isValidEmail(ownerEmail)) {
throw createClientError(`Job owner email "${ownerEmail}" is not valid.`);
}
return [
{
email: ownerEmail,
name: getJobOwnerName(jobData, ownerEmail),
role: "SIGNER"
}
];
}
async function getDocumensoClient({ bodyshopid, req }) {
const { apiKey } = await getDocumensoConfig({ bodyshopid, req });
return new Documenso({
apiKey,
serverURL: DOCUMENSO_SERVER_URL
});
}
async function getDocumensoConfig({ bodyshopid, req }) {
const client = req.userGraphQLClient;
const {
bodyshops_by_pk: { documenso_api_key }
} = await client.request(QUERY_DOCUMENSO_KEY, { bodyshopid });
return {
apiKey: documenso_api_key,
serverURL: DOCUMENSO_SERVER_URL
};
}
async function getDocumensoDocument({ apiKey, documentId }) {
const { data } = await axios.get(`${DOCUMENSO_SERVER_URL}/document/${encodeURIComponent(documentId)}`, {
headers: {
Accept: "application/json",
Authorization: apiKey
}
});
return data;
}
async function createEsignDocumentFromPdf({ req, bodyshop, pdfBuffer, esigData, fileName }) {
const resolvedEsigData = getDefaultEsignData({ esigData, bodyshop, fileName });
const fileBlob = new Blob([pdfBuffer], { type: "application/pdf" });
const jobid = req.body.jobid;
const client = req.userGraphQLClient;
const { jobs_by_pk: jobData } = await client.request(QUERY_JOB_FOR_SIGNATURE, { jobid });
const recipients = getJobOwnerRecipients(jobData);
const documensoConfig = await getDocumensoConfig({ bodyshopid: bodyshop.id, req });
const documenso = new Documenso({
apiKey: documensoConfig.apiKey,
serverURL: documensoConfig.serverURL
});
const createDocumentResponse = await documenso.documents.create({
payload: {
title: resolvedEsigData.title,
externalId: `${jobid}|${req.user?.email}`,
recipients,
meta: {
timezone: bodyshop.timezone,
dateFormat: "MM/dd/yyyy hh:mm a",
language: "en",
subject: resolvedEsigData.subject,
message: resolvedEsigData.message
}
},
file: fileBlob
});
const documentResult = await getDocumensoDocument({
apiKey: documensoConfig.apiKey,
documentId: createDocumentResponse.id
});
if (resolvedEsigData?.fields && resolvedEsigData.fields.length > 0) {
try {
await documenso.envelopes.fields.createMany({
envelopeId: createDocumentResponse.envelopeId,
data: resolvedEsigData.fields.map((sigField) => ({ ...sigField, recipientId: documentResult.recipients[0].id }))
});
} catch (error) {
logger.log(`esig-new-fields-error`, "ERROR", "esig", "api", {
message: error.message,
stack: error.stack,
body: req.body
});
}
}
const presignToken = await documenso.embedding.embeddingPresignCreateEmbeddingPresignToken({});
await client.request(INSERT_ESIGNATURE_DOCUMENT, {
audit: {
jobid,
bodyshopid: bodyshop.id,
operation: `Esignature document created. Subject: ${resolvedEsigData.subject || "No subject"}, Message: ${resolvedEsigData.message || "No message"}. Document ID: ${createDocumentResponse.id} Envlope ID: ${createDocumentResponse.envelopeId}`,
useremail: req.user?.email,
type: "esig-create"
},
esig: {
jobid,
external_document_id: createDocumentResponse.id.toString(),
subject: resolvedEsigData.subject || "No subject",
message: resolvedEsigData.message || "No message",
title: resolvedEsigData.title || "No title",
status: "DRAFT",
recipients: recipients
}
});
return {
token: presignToken.token,
documentId: createDocumentResponse.id,
envelopeId: createDocumentResponse.envelopeId
};
}
async function distributeDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { documentId, bodyshopid } = req.body;
const documenso = await getDocumensoClient({ bodyshopid, req });
const distributeResult = await documenso.documents.distribute({
documentId
});
await client.request(DISTRIBUTE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId.toString(),
esig_update: {
status: "SENT"
},
audit: {
jobid: req.body.jobid,
bodyshopid: req.body.bodyshopid,
operation: `Esignature document with title ${distributeResult.title} (ID: ${documentId}) distributed to recipients.`,
useremail: req.user?.email,
type: "esig-distribute"
}
});
res.json({ success: true, distributeResult });
} catch (error) {
logger.log(`esig-distribute-error`, "ERROR", "esig", "api", {
message: error.message,
stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while distributing the document.", message: error.message });
}
}
async function redistributeDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { documentId, bodyshopid } = req.body;
const documensoConfig = await getDocumensoConfig({ bodyshopid, req });
const documenso = new Documenso({
apiKey: documensoConfig.apiKey,
serverURL: documensoConfig.serverURL
});
const document = await getDocumensoDocument({
apiKey: documensoConfig.apiKey,
documentId: parseInt(documentId)
});
const distributeResult = await documenso.documents.redistribute({
documentId: parseInt(documentId),
recipients: document.recipients.filter((r) => r.signingStatus === "NOT_SIGNED").map((r) => r.id)
});
await client.request(INSERT_ESIG_AUDIT_TRAIL, {
obj: {
jobid: req.body.jobid,
bodyshopid: req.body.bodyshopid,
operation: `Esignature document with title ${distributeResult.title} (ID: ${documentId}) redistributed to recipients.`,
useremail: req.user?.email,
type: "esig-redistribute"
}
});
res.json({ success: true, distributeResult });
} catch (error) {
logger.log(`esig-redistribute-error`, "ERROR", "esig", "api", {
message: error.message,
stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while redistributing the document.", message: error.message });
}
}
async function deleteDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { documentId, bodyshopid } = req.body;
const { esignature_documents } = await client.request(QUERY_ESIGNATURE_BY_EXTERNAL_ID, {
external_document_id: documentId.toString()
});
if (!esignature_documents || esignature_documents.length === 0) {
//return res.status(404).json({ error: "Document not found" });
}
const documenso = await getDocumensoClient({ bodyshopid, req });
const deleteResult = await documenso.documents.delete({
documentId: documentId
});
await client.request(UPDATE_ESIGNATURE_DOCUMENT, {
external_document_id: documentId.toString(),
esig_update: {
status: "DELETED"
}
});
res.json({ success: true, deleteResult });
} catch (error) {
logger.log(`esig-delete-error`, "ERROR", "esig", "api", {
message: error.message,
stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while deleting the document." });
}
}
async function viewDocument(req, res) {
try {
const { documentId, bodyshopid } = req.body;
const documenso = await getDocumensoClient({ bodyshopid, req });
const document = await documenso.document.documentDownload({
documentId: parseInt(documentId)
});
res.json({ success: true, document });
} catch (error) {
logger.log(`esig-view-error`, "ERROR", "esig", "api", {
message: error.message,
stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while retrieving the document.", message: error.message });
}
}
async function newEsignDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { bodyshop } = req.body;
const { pdf: fileBuffer, esigData } = await RenderTemplate({ client, req });
const result = await createEsignDocumentFromPdf({
req,
bodyshop,
pdfBuffer: fileBuffer,
esigData
});
res.json(result);
} catch (error) {
logger.log(`esig-new-error`, "ERROR", "esig", "api", {
message: error.message,
stack: error.stack,
body: _.omit(req.body, ["bodyshop"]) // bodyshop can be large, so we omit it from the logs
});
res
.status(error.statusCode || 500)
.json({ error: "An error occurred while creating the e-sign document.", message: error.message });
}
}
async function newCustomEsignDocument(req, res) {
try {
const bodyshop = parseJsonField(req.body.bodyshop, req.body.bodyshop);
const esigData = parseJsonField(req.body.esigData, {});
const uploadedDocument = req.file;
if (!uploadedDocument?.buffer) {
return res.status(400).json({ error: "A PDF document is required." });
}
if (uploadedDocument.mimetype !== "application/pdf") {
return res.status(400).json({ error: "Only PDF documents can be used for e-signature." });
}
req.body.bodyshop = bodyshop;
const fileName = uploadedDocument.originalname?.replace(/\.[^.]+$/, "") || undefined;
const result = await createEsignDocumentFromPdf({
req,
bodyshop,
pdfBuffer: uploadedDocument.buffer,
esigData,
fileName
});
res.json(result);
} catch (error) {
logger.log(`esig-new-custom-error`, "ERROR", "esig", "api", {
message: error.message,
stack: error.stack,
body: _.omit(req.body, ["bodyshop"]) // bodyshop can be large, so we omit it from the logs
});
res
.status(error.statusCode || 500)
.json({ error: "An error occurred while creating the custom e-sign document.", message: error.message });
}
}
async function RenderTemplate({ req }) {
const jsrAuth = jsrAuthString();
const jsreportClient = new jsreport(JSR_SERVER, process.env.JSR_USER, process.env.JSR_PASSWORD);
const { templateObject, bodyshop } = req.body;
let { contextData, useShopSpecificTemplate, shopSpecificFolder, esigData } = await fetchContextData({
templateObject,
jsrAuth,
req
});
const { ignoreCustomMargins } = { ignoreCustomMargins: false }; // Templates[templateObject.name];
let reportRequest = {
template: {
name: useShopSpecificTemplate ? `/${bodyshop.imexshopid}/${templateObject.name}` : `/${templateObject.name}`,
recipe: "chrome-pdf",
...(!ignoreCustomMargins && {
chrome: {
marginTop:
bodyshop.logo_img_path && bodyshop.logo_img_path.headerMargin && bodyshop.logo_img_path.headerMargin > 36
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path && bodyshop.logo_img_path.footerMargin && bodyshop.logo_img_path.footerMargin > 50
? bodyshop.logo_img_path.footerMargin
: "50px"
}
})
},
data: {
...contextData,
...templateObject.variables,
...templateObject.context,
headerpath: shopSpecificFolder ? `/${bodyshop.imexshopid}/header.html` : `/GENERIC/header.html`,
footerpath: shopSpecificFolder ? `/${bodyshop.imexshopid}/footer.html` : `/GENERIC/footer.html`,
bodyshop: bodyshop,
esignature: true,
filters: templateObject?.filters,
sorters: templateObject?.sorters,
offset: bodyshop.timezone, //dayjs().utcOffset(),
defaultSorters: templateObject?.defaultSorters
}
};
const render = await jsreportClient.render(reportRequest);
//Check render object and download. It should be the PDF?
const pdfBuffer = await render.body();
return { pdf: pdfBuffer, esigData };
}
const fetchContextData = async ({ templateObject, jsrAuth, req }) => {
const { bodyshop } = req.body;
const folders = await axios.get(`${JSR_SERVER}/odata/folders`, {
headers: { Authorization: jsrAuth }
});
const shopSpecificFolder = folders.data.value.find((f) => f.name === bodyshop.imexshopid);
const jsReportQueries = await axios.get(`${JSR_SERVER}/odata/assets?$filter=name eq '${templateObject.name}.query'`, {
headers: { Authorization: jsrAuth }
});
const jsReportEsig = await axios.get(`${JSR_SERVER}/odata/assets?$filter=name eq '${templateObject.name}.esig'`, {
headers: { Authorization: jsrAuth }
});
let templateQueryToExecute;
let esigData;
let useShopSpecificTemplate = false;
// let shopSpecificTemplate;
if (shopSpecificFolder) {
let shopSpecificTemplate = jsReportQueries.data.value.find(
(f) => f?.folder?.shortid === shopSpecificFolder.shortid
);
if (shopSpecificTemplate) {
useShopSpecificTemplate = true;
templateQueryToExecute = atob(shopSpecificTemplate.content);
}
let shopSpecificEsig = jsReportEsig.data.value.find((f) => f?.folder?.shortid === shopSpecificFolder.shortid);
if (shopSpecificEsig) {
esigData = atob(shopSpecificEsig.content);
}
}
if (!templateQueryToExecute) {
const generalTemplate = jsReportQueries.data.value.find((f) => !f.folder);
useShopSpecificTemplate = false;
templateQueryToExecute = atob(generalTemplate.content);
}
if (!esigData) {
const generalTemplate = jsReportEsig.data.value.find((f) => !f.folder);
useShopSpecificTemplate = false;
if (generalTemplate && generalTemplate.content) {
esigData = atob(generalTemplate?.content);
}
}
const client = req.userGraphQLClient;
// In the print center, we will never have sorters or filters.
// We have no template filters or sorters, so we can just execute the query and return the data
// if (!hasFilters && !hasSorters && !hasDefaultSorters) {
let contextData = {};
if (templateQueryToExecute) {
const data = await client.request(templateQueryToExecute, templateObject.variables);
contextData = data;
}
let parsedEsigData;
try {
parsedEsigData = esigData ? JSON.parse(esigData) : null;
} catch (error) {
logger.log(`esig-data-parse-error`, "ERROR", "esig", "api", {
message: error.message,
stack: error.stack,
esigData,
body: req.body
});
parsedEsigData = {};
}
return {
contextData,
useShopSpecificTemplate,
shopSpecificFolder,
esigData: parsedEsigData
};
// }
// return await generateTemplate(templateQueryToExecute, templateObject, useShopSpecificTemplate, shopSpecificFolder);
};
module.exports = {
newEsignDocument,
newCustomEsignDocument,
distributeDocument,
redistributeDocument,
deleteDocument,
viewDocument,
getDocumensoClient
};