feature/IO-2433-esignature - Code review fixes

This commit is contained in:
Dave
2026-05-13 12:09:18 -04:00
parent f5d33a2386
commit e74be56681
14 changed files with 146 additions and 52 deletions

View File

@@ -8,6 +8,7 @@ import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -28,6 +29,10 @@ export function EsignatureCustomDocument({ bodyshop, jobId, setEsignatureContext
const notification = useNotification();
const { t } = useTranslation();
if (!hasDocumensoApiKey(bodyshop)) {
return null;
}
const uploadCustomDocument = async ({ file, onError, onSuccess }) => {
const formData = new FormData();
formData.append("document", file);

View File

@@ -9,6 +9,7 @@ import { selectEsignature } from "../../redux/modals/modals.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useState } from "react";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
esignatureModal: selectEsignature,
@@ -25,6 +26,11 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
const { open, context } = esignatureModal;
const { token, envelopeId, documentId, jobid } = context;
const [distributing, setDistributing] = useState(false);
if (!hasDocumensoApiKey(bodyshop)) {
return null;
}
return (
<Modal
open={open}

View File

@@ -14,6 +14,7 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import axios from "axios";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -26,6 +27,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobAuditTrail);
export function JobAuditTrail({ bodyshop, jobId }) {
const { t } = useTranslation();
const notification = useNotification();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const { loading, data, refetch } = useQuery(QUERY_AUDIT_TRAIL, {
variables: { jobid: jobId },
skip: !jobId,
@@ -326,18 +328,20 @@ export function JobAuditTrail({ bodyshop, jobId }) {
/>
</Card>
</Col>
<Col span={24}>
<Card title={t("jobs.labels.esignatures")}>
<ResponsiveTable
loading={loading}
columns={esigColumns}
mobileColumnKeys={["title", "status"]}
rowKey="id"
scroll={{ x: true }}
dataSource={data ? data.esignature_documents : []}
/>
</Card>
</Col>
{esignatureEnabled && (
<Col span={24}>
<Card title={t("jobs.labels.esignatures")}>
<ResponsiveTable
loading={loading}
columns={esigColumns}
mobileColumnKeys={["title", "status"]}
rowKey="id"
scroll={{ x: true }}
dataSource={data ? data.esignature_documents : []}
/>
</Card>
</Col>
)}
</Row>
);
}

View File

@@ -1,4 +1,3 @@
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import { Checkbox, Form } from "antd";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
@@ -9,18 +8,18 @@ import PropTypes from "prop-types";
* @param form
* @param disabled
* @param onHeaderChange
* @param scenarioKeys
* @returns {JSX.Element}
* @constructor
*/
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange, scenarioKeys }) => {
const { t } = useTranslation();
// Subscribe to all form values so that this component re-renders on changes.
const formValues = Form.useWatch([], form) || {};
// Determine if all scenarios for this channel are checked.
const allChecked =
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
const allChecked = scenarioKeys.length > 0 && scenarioKeys.every((scenario) => formValues[scenario]?.[channel]);
const onChange = (e) => {
const checked = e.target.checked;
@@ -28,7 +27,7 @@ const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange
const currentValues = form.getFieldsValue();
// Update each scenario for this channel.
const newValues = { ...currentValues };
notificationScenarios.forEach((scenario) => {
scenarioKeys.forEach((scenario) => {
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
});
// Update form values.
@@ -50,7 +49,8 @@ ColumnHeaderCheckbox.propTypes = {
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
form: PropTypes.object.isRequired,
disabled: PropTypes.bool,
onHeaderChange: PropTypes.func
onHeaderChange: PropTypes.func,
scenarioKeys: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default ColumnHeaderCheckbox;

View File

@@ -12,12 +12,13 @@ import {
UPDATE_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATIONS_AUTOADD
} from "../../graphql/user.queries.js";
import { notificationScenarios, notificationScenarioDefaults } from "../../utils/jobNotificationScenarios.js";
import { getNotificationScenarios, 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";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
/**
* Notifications Settings Form
@@ -35,6 +36,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification();
const isEmployee = useIsEmployee(bodyshop, currentUser);
const notificationScenarios = getNotificationScenarios({ includeEsign: hasDocumensoApiKey(bodyshop) });
// Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
@@ -66,7 +68,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
setInitialAutoAdd(autoAdd);
setIsDirty(false); // Reset dirty state when new data loads
}
}, [data, form]);
}, [data, form, notificationScenarios]);
// Handle toggle of notifications_autoadd
const handleAutoAddToggle = async (checked) => {
@@ -137,7 +139,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
width: "80%"
},
{
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />,
title: (
<ColumnHeaderCheckbox
channel="app"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "app",
key: "app",
align: "center",
@@ -148,7 +157,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
)
},
{
title: <ColumnHeaderCheckbox channel="email" form={form} onHeaderChange={() => setIsDirty(true)} />,
title: (
<ColumnHeaderCheckbox
channel="email"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "email",
key: "email",
align: "center",
@@ -163,7 +179,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
// Currently disabled for prod
if (!import.meta.env.PROD) {
columns.push({
title: <ColumnHeaderCheckbox channel="fcm" form={form} onHeaderChange={() => setIsDirty(true)} />,
title: (
<ColumnHeaderCheckbox
channel="fcm"
form={form}
onHeaderChange={() => setIsDirty(true)}
scenarioKeys={notificationScenarios}
/>
),
dataIndex: "fcm",
key: "fcm",
align: "center",

View File

@@ -12,6 +12,7 @@ import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import axios from "axios";
import { setModalContext } from "../../redux/modals/modals.actions.js";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter,
@@ -41,6 +42,7 @@ export function PrintCenterItemComponent({
const [loading, setLoading] = useState(false);
const { context } = printCenterModal;
const notification = useNotification();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const renderToNewWindow = async () => {
setLoading(true);
@@ -96,7 +98,7 @@ export function PrintCenterItemComponent({
<li>
<Space wrap>
{item.title}
<SignatureFilled onClick={esignatureGenerate} />
{esignatureEnabled && <SignatureFilled onClick={esignatureGenerate} />}
<PrinterOutlined onClick={renderToNewWindow} />
{!technician ? (
<MailOutlined

View File

@@ -15,6 +15,7 @@ import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs
import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter,
@@ -39,6 +40,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const Templates = !hasDMSKey
? Object.keys(tempList)
@@ -98,7 +100,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
extra={
<Space wrap>
<PrintCenterJobsLabels jobId={jobId} />
<EsignatureCustomDocument jobId={jobId} />
{esignatureEnabled && <EsignatureCustomDocument jobId={jobId} />}
<Jobd3RdPartyModal jobId={jobId} job={job} />
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton />
</Space>

View File

@@ -53,6 +53,7 @@ export const QUERY_BODYSHOP = gql`
phone
federal_tax_id
id
documenso_api_key
insurance_vendor_id
logo_img_path
md_ro_statuses

View File

@@ -57,6 +57,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
import UndefinedToNull from "../../utils/undefinedtonull";
const mapStateToProps = createStructuredSelector({
@@ -105,6 +106,7 @@ export function JobsDetailPage({
});
const notification = useNotification();
const { scenarioNotificationsOn } = useSocket();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
useEffect(() => {
//form.setFieldsValue(transormJobToForm(job));
@@ -286,7 +288,7 @@ export function JobsDetailPage({
>
{t("general.labels.refresh")}
</Button>
<EsignatureCustomDocument jobId={job.id} />
{esignatureEnabled && <EsignatureCustomDocument jobId={job.id} />}
<JobsChangeStatus job={job} />
<JobSyncButton job={job} />
<Button

View File

@@ -31,6 +31,7 @@ import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import { lazyDev } from "../../utils/lazyWithPreload.jsx";
import EsignatureModalContainer from "../../components/esignature-modal/esignature-modal.container.jsx";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
const PrintCenterModalContainer = lazyDev(
() => import("../../components/print-center-modal/print-center-modal.container")
@@ -128,6 +129,7 @@ const mapStateToProps = createStructuredSelector({
export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, currentUser }) {
const { t } = useTranslation();
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const [chatVisible] = useState(false);
const didMount = useRef(false);
@@ -183,7 +185,7 @@ export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, cu
<TaskUpsertModalContainer />
<BreadCrumbs />
<BillEnterModalContainer />
<EsignatureModalContainer />
{esignatureEnabled && <EsignatureModalContainer />}
<JobCostingModal />
<ReportCenterModal />
<EmailOverlayContainer />

View File

@@ -0,0 +1,7 @@
export const hasDocumensoApiKey = (bodyshop) => {
if (typeof bodyshop?.documenso_api_key === "string") {
return bodyshop.documenso_api_key.trim().length > 0;
}
return Boolean(bodyshop?.documenso_api_key);
};

View File

@@ -2,7 +2,7 @@
* @description This file contains the scenarios for job notifications.
* @type {string[]}
*/
const notificationScenarios = [
const baseNotificationScenarios = [
"job-assigned-to-me",
"bill-posted",
"critical-parts-status-changed",
@@ -16,13 +16,21 @@ const notificationScenarios = [
"job-added-to-production",
"job-status-change",
"payment-collected-completed",
"alternate-transport-changed",
"alternate-transport-changed"
// "supplement-imported", // Disabled for now
];
const esignNotificationScenarios = [
"esign-document-opened",
"esign-document-completed",
"esign-document-upload-failed"
// "supplement-imported", // Disabled for now
];
const notificationScenarios = [...baseNotificationScenarios, ...esignNotificationScenarios];
const getNotificationScenarios = ({ includeEsign = true } = {}) =>
includeEsign ? notificationScenarios : baseNotificationScenarios;
/**
* 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.
@@ -34,4 +42,4 @@ const notificationScenarioDefaults = {
"esign-document-upload-failed": { app: true, email: false, fcm: false }
};
export { notificationScenarios, notificationScenarioDefaults };
export { esignNotificationScenarios, getNotificationScenarios, notificationScenarios, notificationScenarioDefaults };

View File

@@ -36,6 +36,41 @@ function getDefaultEsignData({ esigData, bodyshop, fileName }) {
};
}
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 client = req.userGraphQLClient;
const { bodyshops_by_pk: { documenso_api_key } } = await client.request(QUERY_DOCUMENSO_KEY, { bodyshopid });
@@ -52,11 +87,7 @@ async function createEsignDocumentFromPdf({ req, bodyshop, pdfBuffer, esigData,
const client = req.userGraphQLClient;
const { jobs_by_pk: jobData } = await client.request(QUERY_JOB_FOR_SIGNATURE, { jobid });
const recipients = [{
email: "patrick@imexsystems.ca",
name: `${jobData.ownr_fn} ${jobData.ownr_ln}`,
role: "SIGNER",
}];
const recipients = getJobOwnerRecipients(jobData);
const documenso = await getDocumensoClient({ bodyshopid: bodyshop.id, req })
@@ -267,7 +298,7 @@ async function newEsignDocument(req, res) {
message: error.message, stack: error.stack,
body: _.omit(req.body, ["bodyshop"]) // bodyshop can be large, so we omit it from the logs
});
res.status(500).json({ error: "An error occurred while creating the e-sign document.", message: error.message });
res.status(error.statusCode || 500).json({ error: "An error occurred while creating the e-sign document.", message: error.message });
}
}
@@ -302,7 +333,7 @@ async function newCustomEsignDocument(req, res) {
message: error.message, stack: error.stack,
body: _.omit(req.body, ["bodyshop"]) // bodyshop can be large, so we omit it from the logs
});
res.status(500).json({ error: "An error occurred while creating the custom e-sign document.", message: error.message });
res.status(error.statusCode || 500).json({ error: "An error occurred while creating the custom e-sign document.", message: error.message });
}
}

View File

@@ -219,8 +219,13 @@ async function handleDocumentCompleted(payload) {
}
};
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", buffer); //TODO: Validate this is the correct type.
formData.append("file", pdfBlob, fileName);
try {
const imexMediaServerResponse = await axios.post(
@@ -232,12 +237,9 @@ async function handleDocumentCompleted(payload) {
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();
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", {
@@ -247,6 +249,7 @@ async function handleDocumentCompleted(payload) {
documentId: payload.id
});
notifyUploadFailure();
throw error;
}
} else {
try {
@@ -274,13 +277,9 @@ async function handleDocumentCompleted(payload) {
}
});
} else {
logger.log(`esig-webhook-s3-upload-error`, "ERROR", "redis", "api", {
message: uploadResult.message,
stack: uploadResult.stack,
jobid: jobid,
documentId: payload.id
});
notifyUploadFailure();
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", {
@@ -290,6 +289,7 @@ async function handleDocumentCompleted(payload) {
documentId: payload.id
});
notifyUploadFailure();
throw error;
}
}
@@ -328,6 +328,7 @@ async function handleDocumentCompleted(payload) {
stack: error.stack,
payload
});
throw error;
}
}