Compare commits

..

1 Commits

Author SHA1 Message Date
Allan Carr
587f4a4492 IO-1366 Extend Audit Log
Extend for Time Ticket Changes, Manual Lines, Manual Job, Bill Edits

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-01 22:51:05 -07:00
15 changed files with 239 additions and 346 deletions

View File

@@ -13,7 +13,6 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day";
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
import AlertComponent from "../alert/alert.component";
import BillFormContainer from "../bill-form/bill-form.container";
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
@@ -135,17 +134,96 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
await Promise.all(updates);
const details = buildBillUpdateAuditDetails({
originalBill: data?.bills_by_pk,
bill,
billlines
});
const details = (() => {
const original = data?.bills_by_pk ?? {};
const updated = { ...bill, billlines };
const fmtVal = (key, val) =>
val == null || val === ""
? "<<empty>>"
: typeof val === "number" && key.toLowerCase().includes("price")
? `$${val.toFixed(2)}`
: val;
const keysToTrack = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"];
const lineVals = (obj) => keysToTrack.map((k) => fmtVal(k, obj[k])).join(", ");
const changed = Object.entries(updated)
.filter(([k, v]) => v != null && v !== "" && k !== "billlines" && k !== "__typename")
.map(([k, v]) => {
const orig = original[k];
if (k === "date") {
const a = orig ? dayjs(orig) : null;
const b = v ? (dayjs.isDayjs(v) ? v : dayjs(v)) : null;
return (a && b && a.isSame(b, "day")) || (!a && !b)
? null
: `date: ${a?.format("YYYY-MM-DD") ?? "<<empty>>"}${b?.format("YYYY-MM-DD") ?? "<<empty>>"}`;
}
return typeof orig === "object" || typeof v === "object" || String(orig) === String(v)
? null
: `${k}: ${fmtVal(k, orig)}${fmtVal(k, v)}`;
})
.filter(Boolean);
const origLines = original.billlines ?? [];
const updLines = updated.billlines ?? [];
const addedObjs = updLines
.filter((l) => !l.id)
.map((u) => ({ label: u.line_desc || u.description || "new line", vals: lineVals(u), handled: false }));
const removedObjs = origLines
.filter((o) => !updLines.some((u) => u.id === o.id))
.map((o) => ({
label: o.line_desc || o.description || o.id || "removed line",
vals: lineVals(o),
handled: false
}));
const labelToAdded = addedObjs.reduce((m, a) => m.set(a.label, [...(m.get(a.label) ?? []), a]), new Map());
const modified = [
...removedObjs.reduce((acc, r) => {
const candidates = labelToAdded.get(r.label) ?? [];
const exact = candidates.find((c) => c.vals === r.vals && !c.handled);
if (exact) {
exact.handled = r.handled = true;
return acc;
} // identical → cancel out
const diff = candidates.find((c) => c.vals !== r.vals && !c.handled);
if (diff) {
diff.handled = r.handled = true;
acc.push(`${r.label}: ${r.vals}${diff.vals}`);
}
return acc;
}, []),
...updLines
.filter((u) => u.id)
.flatMap((u) => {
const o = origLines.find((x) => x.id === u.id);
if (!o) return [];
const diffs = keysToTrack
.filter((k) => String(o[k]) !== String(u[k]))
.map((k) => `${fmtVal(k, o[k])}${fmtVal(k, u[k])}`);
return diffs.length ? [`${u.line_desc || u.description || u.id}: ${diffs.join("; ")}`] : [];
})
];
[
["added", addedObjs.filter((a) => !a.handled).map((a) => `+${a.label} (${a.vals})`)],
["removed", removedObjs.filter((r) => !r.handled).map((r) => `-${r.label} (${r.vals})`)],
["modified", modified]
].forEach(([type, items]) => {
if (items.length) changed.push(`billlines ${type}: ${items.join(" | ")}`);
});
return changed.length ? changed.join("; ") : bill.invoice_number || "No changes";
})();
insertAuditTrail({
jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
jobid: bill.jobid,
billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
type: "billupdated"
operation: AuditTrailMapping.billupdated(bill.invoice_number, details)
});
await refetch();

View File

@@ -16,7 +16,6 @@ import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({
jobLineEditModal: selectJobLineEditModal,
@@ -80,7 +79,12 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
});
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)),
operation: AuditTrailMapping.jobmanuallineinsert(
Object.entries(values)
.filter(([k, v]) => v != null && v !== "" && k !== "ah_detail_line" && k !== "prt_dsmk_p")
.map(([k, v]) => `${k}: ${v}`)
.join("; ")
),
type: "jobmanuallineinsert"
});
} else {
@@ -115,11 +119,23 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.joblineupdate(
values.line_desc || jobLineEditModal.context.line_desc || "manual line",
buildJobLineUpdateAuditDetails({
originalLine: jobLineEditModal.context,
values
})
(() => {
const original = jobLineEditModal.context || {};
const changed = Object.entries(values)
.filter(([k, v]) => v != null && v !== "" && k !== "ah_detail_line" && k !== "prt_dsmk_p")
.map(([k, v]) => {
const orig = original[k];
if (String(orig) === String(v)) return null;
const fmt = (key, val) => {
if (val == null || val === "") return "<<empty>>";
if (typeof val === "number" && key.toLowerCase().includes("price")) return `$${val.toFixed(2)}`;
return val;
};
return `${k}: ${fmt(k, orig)}${fmt(k, v)}`;
})
.filter(Boolean);
return changed.length ? changed.join("; ") : "No changes";
})()
),
type: "joblineupdate"
});

View File

@@ -17,7 +17,6 @@ import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time
import TimeTicketModalComponent from "./time-ticket-modal.component";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket,
@@ -54,76 +53,86 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const employees = EmployeeAutoCompleteData?.employees ?? [];
const handleFinish = (values) => {
// Save submitted values so we can compute audit-trail details after the mutation completes
lastSubmittedRef.current = values;
setLoading(true);
const isEdit = Boolean(timeTicketModal.context.id);
const emps = employees.filter((employee) => employee.id === values.employeeid);
const mutation = isEdit
? updateTicket({
variables: {
timeticketId: timeTicketModal.context.id,
timeticket: {
const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid);
if (timeTicketModal.context.id) {
updateTicket({
variables: {
timeticketId: timeTicketModal.context.id,
timeticket: {
...values,
rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
} else {
//Get selected employee rate.
insertTicket({
variables: {
timeTicketInput: [
{
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
}
}
})
: insertTicket({
variables: {
timeTicketInput: [
{
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
}
]
}
});
mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError);
]
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
}
};
const handleMutationSuccess = (result, isEdit) => {
const handleMutationSuccess = () => {
notification.success({
title: t("timetickets.successes.created")
});
const savedTicket =
result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {};
const originalTicket = timeTicketModal.context?.timeticket ?? {};
const submittedValues = {
...(lastSubmittedRef.current ?? {}),
date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null,
employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null,
jobid:
lastSubmittedRef.current?.jobid ??
savedTicket.jobid ??
timeTicketModal.context.jobId ??
originalTicket.job?.id ??
originalTicket.jobid ??
null
};
const auditSummary = buildTimeTicketAuditSummary({
originalTicket,
submittedValues,
employees
});
const timeticket = timeTicketModal.context?.timeticket ?? {};
const original = timeticket || {};
const submitted = lastSubmittedRef.current || {};
if (auditSummary.jobid) {
insertAuditTrail({
jobid: auditSummary.jobid,
operation: isEdit
? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details)
: AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details),
type: isEdit ? "timeticketupdated" : "timeticketcreated"
});
}
const fmt = (key, val) => {
if (val == null || val === "") return "<<empty>>";
const k = key.toLowerCase();
if (dayjs.isDayjs?.(val)) return dayjs(val).format(k.includes("clock") ? "YYYY-MM-DD HH:mm" : "YYYY-MM-DD");
if (typeof val === "number")
return k.includes("hrs")
? val.toFixed(1)
: k.includes("rate") || k.includes("price")
? `$${val.toFixed(2)}`
: String(val);
if (key === "employeeid") {
const emp = EmployeeAutoCompleteData?.employees?.find(({ id }) => id === val);
return emp ? `${emp.first_name} ${emp.last_name}` : String(val);
}
return String(val);
};
const changed = Object.entries(submitted)
.filter(([, v]) => v != null && v !== "")
.map(([k, v]) => {
const origVal = k === "jobid" ? (original.job?.id ?? original.jobid ?? original[k]) : original[k];
return String(fmt(k, origVal)) !== String(fmt(k, v)) ? `${k}: ${fmt(k, origVal)}${fmt(k, v)}` : null;
})
.filter(Boolean);
insertAuditTrail({
jobid: timeticket.job?.id ?? timeticket.jobid,
operation: AuditTrailMapping.timeticketupdated(
[original.employee.first_name, original.employee.last_name].filter(Boolean).join(" "),
original.date ? dayjs(original.date).format("YYYY-MM-DD") : "<<empty>>",
changed.length ? changed.join("; ") : "No changes"
)
});
// Refresh parent screens (Job Labor tab, etc.)
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();

View File

@@ -122,7 +122,7 @@
"billdeleted": "Bill with invoice number {{invoice_number}} deleted.",
"billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.",
"billposted": "Bill with invoice number {{invoice_number}} posted.",
"billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{details}}.",
"billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{values}}.",
"failedpayment": "Failed payment attempt.",
"jobassignmentchange": "Employee {{name}} assigned to {{operation}}",
"jobassignmentremoved": "Employee assignment removed for {{operation}}",
@@ -137,9 +137,9 @@
"jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.",
"jobinvoiced": "Job has been invoiced.",
"jobioucreated": "IOU Created.",
"joblineupdate": "Job line {{lineDescription}} updated with the following details: {{details}}.",
"joblineupdate": "Job line {{line_desc}} updated.",
"jobmanualcreate": "Job manually created.",
"jobmanuallineinsert": "Job line manually added with the following details: {{details}}.",
"jobmanuallineinsert": "Job line manually added with the following details: {{values}}.",
"jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.",
"jobnoteadded": "Note added to Job.",
"jobnotedeleted": "Note deleted from Job.",
@@ -156,7 +156,6 @@
"tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}",
"tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}",
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}",
"timeticketcreated": "Time Ticket for {{employee}} on {{date}} created with the following details: {{details}}.",
"timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}"
}
},

View File

@@ -122,6 +122,7 @@
"billdeleted": "",
"billposted": "",
"billmarkforreexport": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -136,6 +137,9 @@
"jobintake": "",
"jobinvoiced": "",
"jobioucreated": "",
"joblineupdate": "",
"jobmanualcreate": "",
"jobmanuallineinsert": "",
"jobmodifylbradj": "",
"jobnoteadded": "",
"jobnotedeleted": "",
@@ -151,7 +155,8 @@
"tasks_deleted": "",
"tasks_uncompleted": "",
"tasks_undeleted": "",
"tasks_updated": ""
"tasks_updated": "",
"timeticketupdated": ""
}
},
"billlines": {

View File

@@ -122,6 +122,7 @@
"billdeleted": "",
"billmarkforreexport": "",
"billposted": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -136,6 +137,9 @@
"jobintake": "",
"jobinvoiced": "",
"jobioucreated": "",
"joblineupdate": "",
"jobmanualcreate": "",
"jobmanuallineinsert": "",
"jobmodifylbradj": "",
"jobnoteadded": "",
"jobnotedeleted": "",
@@ -151,7 +155,8 @@
"tasks_deleted": "",
"tasks_uncompleted": "",
"tasks_undeleted": "",
"tasks_updated": ""
"tasks_updated": "",
"timeticketupdated": ""
}
},
"billlines": {

View File

@@ -10,7 +10,7 @@ const AuditTrailMapping = {
billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }),
billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }),
billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }),
billupdated: (invoice_number, details) => i18n.t("audit_trail.messages.billupdated", { invoice_number, details }),
billupdated: (invoice_number, values) => i18n.t("audit_trail.messages.billupdated", { invoice_number, values }),
jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }),
jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }),
jobchecklist: (type, inproduction, status) =>
@@ -26,10 +26,9 @@ const AuditTrailMapping = {
jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }),
jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"),
jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"),
joblineupdate: (lineDescription, details) =>
i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }),
joblineupdate: (line_desc) => i18n.t("audit_trail.messages.joblineupdate", { line_desc }),
jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"),
jobmanuallineinsert: (details) => i18n.t("audit_trail.messages.jobmanuallineinsert", { details }),
jobmanuallineinsert: (values) => i18n.t("audit_trail.messages.jobmanuallineinsert", { values }),
jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }),
jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"),
jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"),
@@ -77,10 +76,7 @@ const AuditTrailMapping = {
title,
uncompletedBy
}),
timeticketcreated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketcreated", { employee, date, details }),
timeticketupdated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details })
timeticketupdated: (employee, date, details) => i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details })
};
export default AuditTrailMapping;

View File

@@ -1,186 +0,0 @@
import dayjs from "./day";
const EMPTY_VALUE = "<<empty>>";
const NO_CHANGES = "No changes";
const BILL_LINE_KEYS = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"];
const JOB_LINE_SKIP_KEYS = new Set(["ah_detail_line", "prt_dsmk_p"]);
const DATE_ONLY_KEYS = new Set(["date"]);
const DATE_TIME_KEYS = new Set(["clockon", "clockoff"]);
const CURRENCY_KEYS = new Set(["actual_price", "actual_cost", "act_price", "db_price", "rate"]);
const HOUR_KEYS = new Set(["productivehrs", "actualhrs", "mod_lb_hrs"]);
const isBlank = (value) => value == null || value === "";
const isStructuredValue = (value) => value != null && typeof value === "object" && !dayjs.isDayjs?.(value);
const formatDate = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD"));
const formatDateTime = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD HH:mm"));
const formatNumber = (value, fractionDigits) =>
typeof value === "number" ? value.toFixed(fractionDigits) : String(value);
const compareValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (dayjs.isDayjs?.(value)) return formatDateTime(value);
return String(value);
};
const buildFieldChangeDetails = ({ keys, original = {}, updated = {}, displayValue, skippedKeys = new Set() }) =>
keys
.filter((key) => key !== "__typename" && !skippedKeys.has(key))
.filter((key) => !isStructuredValue(original[key]) && !isStructuredValue(updated[key]))
.map((key) => {
if (compareValue(key, original[key]) === compareValue(key, updated[key])) return null;
return `${key}: ${displayValue(key, original[key])} -> ${displayValue(key, updated[key])}`;
})
.filter(Boolean);
const formatBillValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
return String(value);
};
const formatJobLineValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
return String(value);
};
const getEmployeeName = (employeeId, employees = [], fallbackEmployee) => {
if (
(employeeId == null || fallbackEmployee?.id === employeeId) &&
(fallbackEmployee?.first_name || fallbackEmployee?.last_name)
) {
return [fallbackEmployee.first_name, fallbackEmployee.last_name].filter(Boolean).join(" ");
}
const employee = employees.find(({ id }) => id === employeeId);
if (employee) {
return [employee.first_name, employee.last_name].filter(Boolean).join(" ");
}
return employeeId ? String(employeeId) : EMPTY_VALUE;
};
const formatTimeTicketValue = (key, value, { employees = [], fallbackEmployee } = {}) => {
if (isBlank(value)) return EMPTY_VALUE;
if (key === "employeeid") return getEmployeeName(value, employees, fallbackEmployee);
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
};
const buildBillLineSummary = (line) =>
BILL_LINE_KEYS.map((key) => `${key}: ${formatBillValue(key, line[key])}`).join(", ");
export function buildBillUpdateAuditDetails({ originalBill = {}, bill = {}, billlines = [] }) {
const updatedBill = { ...bill, billlines };
const billKeys = Array.from(new Set([...Object.keys(originalBill), ...Object.keys(updatedBill)])).filter(
(key) => key !== "billlines"
);
const changed = buildFieldChangeDetails({
keys: billKeys,
original: originalBill,
updated: updatedBill,
displayValue: formatBillValue
});
const originalBillLines = originalBill.billlines ?? [];
const updatedBillLines = updatedBill.billlines ?? [];
const addedLines = updatedBillLines
.filter((line) => !line.id)
.map((line) => `+${line.line_desc || line.description || "new line"} (${buildBillLineSummary(line)})`);
const removedLines = originalBillLines
.filter((line) => !updatedBillLines.some((updatedLine) => updatedLine.id === line.id))
.map(
(line) => `-${line.line_desc || line.description || line.id || "removed line"} (${buildBillLineSummary(line)})`
);
const modifiedLines = updatedBillLines
.filter((line) => line.id)
.flatMap((line) => {
const originalLine = originalBillLines.find(({ id }) => id === line.id);
if (!originalLine) return [];
const lineChanges = buildFieldChangeDetails({
keys: BILL_LINE_KEYS,
original: originalLine,
updated: line,
displayValue: formatBillValue
});
if (!lineChanges.length) return [];
return [`${line.line_desc || line.description || line.id}: ${lineChanges.join("; ")}`];
});
if (addedLines.length) changed.push(`billlines added: ${addedLines.join(" | ")}`);
if (removedLines.length) changed.push(`billlines removed: ${removedLines.join(" | ")}`);
if (modifiedLines.length) changed.push(`billlines modified: ${modifiedLines.join(" | ")}`);
return changed.length ? changed.join("; ") : NO_CHANGES;
}
export function buildJobLineInsertAuditDetails(values = {}) {
const details = Object.entries(values)
.filter(([key, value]) => !JOB_LINE_SKIP_KEYS.has(key) && !isBlank(value))
.map(([key, value]) => `${key}: ${formatJobLineValue(key, value)}`);
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildJobLineUpdateAuditDetails({ originalLine = {}, values = {} }) {
const details = buildFieldChangeDetails({
keys: Object.keys(values),
original: originalLine,
updated: values,
displayValue: formatJobLineValue,
skippedKeys: JOB_LINE_SKIP_KEYS
});
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildTimeTicketAuditSummary({ originalTicket = {}, submittedValues = {}, employees = [] }) {
const normalizedOriginal = {
...originalTicket,
jobid: originalTicket.job?.id ?? originalTicket.jobid ?? null
};
const details = buildFieldChangeDetails({
keys: Object.keys(submittedValues),
original: normalizedOriginal,
updated: submittedValues,
displayValue: (key, value) =>
formatTimeTicketValue(key, value, {
employees,
fallbackEmployee: key === "employeeid" ? normalizedOriginal.employee : null
})
});
const employeeName = getEmployeeName(
submittedValues.employeeid ?? normalizedOriginal.employeeid,
employees,
normalizedOriginal.employee
);
return {
date: formatDate(submittedValues.date ?? normalizedOriginal.date),
details: details.length ? details.join("; ") : NO_CHANGES,
employeeName,
jobid: submittedValues.jobid ?? normalizedOriginal.jobid ?? null
};
}

View File

@@ -1,17 +1,20 @@
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { InstanceRegion, InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("../utils/instanceMgr");
const { isString, isEmpty } = require("lodash");
const CHATTER_BASE_URL = process.env.CHATTER_API_BASE_URL || "https://api.chatterresearch.com";
const AWS_REGION = process.env.AWS_REGION || "ca-central-1";
// Configure SecretsManager client with localstack support
const secretsClientOptions = {
region: InstanceRegion(),
region: AWS_REGION,
credentials: defaultProvider()
};
if (InstanceIsLocalStackEnabled()) {
secretsClientOptions.endpoint = InstanceLocalStackEndpoint();
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
if (isLocal) {
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
}
const secretsClient = new SecretsManagerClient(secretsClientOptions);

View File

@@ -3,7 +3,7 @@ const Dinero = require("dinero.js");
const moment = require("moment-timezone");
const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default;
const { InstanceIsLocalStackEnabled } = require("../utils/instanceMgr");
const { isString, isEmpty } = require("lodash");
const fs = require("fs");
const client = require("../graphql-client/graphql-client").client;
const { sendServerEmail, sendMexicoBillingEmail } = require("../email/sendemail");
@@ -35,9 +35,10 @@ const S3_BUCKET_NAME = InstanceManager({
rome: "rome-carfax-uploads"
});
const region = InstanceManager.InstanceRegion;
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
const uploadToS3 = (jsonObj, bucketName = S3_BUCKET_NAME) => {
const webPath = InstanceIsLocalStackEnabled()
const webPath = isLocal
? `https://${bucketName}.s3.localhost.localstack.cloud:4566/${jsonObj.filename}`
: `https://${bucketName}.s3.${region}.amazonaws.com/${jsonObj.filename}`;

View File

@@ -5,8 +5,7 @@ const logger = require("../utils/logger");
const fs = require("fs");
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("../utils/instanceMgr");
const { isString, isEmpty } = require("lodash");
let Client = require("ssh2-sftp-client");
const client = require("../graphql-client/graphql-client").client;
@@ -152,8 +151,10 @@ async function getPrivateKey() {
credentials: defaultProvider()
};
if (InstanceIsLocalStackEnabled()) {
secretsClientOptions.endpoint = InstanceLocalStackEndpoint();
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
if (isLocal) {
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
}
const client = new SecretsManagerClient(secretsClientOptions);

View File

@@ -1,17 +1,20 @@
const { isString, isEmpty } = require("lodash");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { InstanceRegion, InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("../utils/instanceMgr");
const { InstanceRegion } = require("../utils/instanceMgr");
const aws = require("@aws-sdk/client-ses");
const nodemailer = require("nodemailer");
const logger = require("../utils/logger");
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
const sesConfig = {
apiVersion: "latest",
credentials: defaultProvider(),
region: InstanceRegion()
};
if (InstanceIsLocalStackEnabled()) {
sesConfig.endpoint = InstanceLocalStackEndpoint();
if (isLocal) {
sesConfig.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
logger.logger.debug(`SES Mailer set to LocalStack end point: ${sesConfig.endpoint}`);
}

View File

@@ -7,24 +7,14 @@
* @property { string | object | function } promanager Return this prop if Rome.
* @property { string | object | function } imex Return this prop if Rome.
*/
const { isString, isEmpty } = require("lodash");
/**
* InstanceManager is a utility function that determines which property to return based on the current instance type.
* @param param0
* @param param0.args
* @param param0.instance
* @param param0.debug
* @param param0.executeFunction
* @param param0.rome
* @param param0.promanager
* @param param0.imex
* @returns {*|null}
* @constructor
*/
const InstanceManager = ({ args, instance, debug, executeFunction, rome, promanager, imex }) => {
function InstanceManager({ args, instance, debug, executeFunction, rome, promanager, imex }) {
let propToReturn = null;
//TODO: Remove after debugging.
if (promanager) {
console.trace("ProManager Prop was used");
}
switch (instance || process.env.INSTANCE) {
case "IMEX":
propToReturn = imex;
@@ -60,42 +50,15 @@ const InstanceManager = ({ args, instance, debug, executeFunction, rome, promana
}
if (executeFunction && typeof propToReturn === "function") return propToReturn(...args);
return propToReturn === undefined ? null : propToReturn;
};
}
/**
* Returns the AWS region to be used for the current instance, which is determined by the INSTANCE environment variable.
* @returns {*}
* @constructor
*/
const InstanceRegion = () =>
exports.InstanceRegion = () =>
InstanceManager({
imex: "ca-central-1",
rome: "us-east-2"
});
/**
* Checks if the instance is configured to use LocalStack by verifying the presence of the LOCALSTACK_HOSTNAME
* environment variable.
* @returns {boolean}
* @constructor
*/
const InstanceIsLocalStackEnabled = () =>
isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
/**
* Returns the LocalStack endpoint URL based on the LOCALSTACK_HOSTNAME environment variable.
* @returns {`http://${*}:4566`}
* @constructor
*/
const InstanceLocalStackEndpoint = () => `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
/**
* Returns the appropriate endpoints for the current instance, which can be used for making API calls or other network
* requests.
* @returns {*|null}
* @constructor
*/
const InstanceEndpoints = () =>
exports.InstanceEndpoints = () =>
InstanceManager({
imex:
process.env?.NODE_ENV === "development"
@@ -111,11 +74,4 @@ const InstanceEndpoints = () =>
: "https://romeonline.io"
});
module.exports = {
InstanceManager,
InstanceRegion,
InstanceIsLocalStackEnabled,
InstanceLocalStackEndpoint,
InstanceEndpoints,
default: InstanceManager
};
exports.default = InstanceManager;

View File

@@ -2,9 +2,10 @@
const InstanceManager = require("../utils/instanceMgr").default;
const winston = require("winston");
const WinstonCloudWatch = require("winston-cloudwatch");
const { isString, isEmpty } = require("lodash");
const { uploadFileToS3 } = require("./s3");
const { v4 } = require("uuid");
const { InstanceRegion, InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("./instanceMgr");
const { InstanceRegion } = require("./instanceMgr");
const getHostNameOrIP = require("./getHostNameOrIP");
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
@@ -47,7 +48,7 @@ const normalizeLevel = (level) => (level ? level.toLowerCase() : LOG_LEVELS.debu
const createLogger = () => {
try {
const isLocal = InstanceIsLocalStackEnabled();
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
const logGroupName = isLocal ? "development" : process.env.CLOUDWATCH_LOG_GROUP;
const winstonCloudwatchTransportDefaults = {
@@ -59,7 +60,7 @@ const createLogger = () => {
};
if (isLocal) {
winstonCloudwatchTransportDefaults.awsOptions.endpoint = InstanceLocalStackEndpoint();
winstonCloudwatchTransportDefaults.awsOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
}
const levelFilter = (levels) => {

View File

@@ -7,7 +7,8 @@ const {
CopyObjectCommand
} = require("@aws-sdk/client-s3");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { InstanceRegion, InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("./instanceMgr");
const { InstanceRegion } = require("./instanceMgr");
const { isString, isEmpty } = require("lodash");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const createS3Client = () => {
@@ -16,8 +17,10 @@ const createS3Client = () => {
credentials: defaultProvider()
};
if (InstanceIsLocalStackEnabled()) {
S3Options.endpoint = InstanceLocalStackEndpoint();
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
if (isLocal) {
S3Options.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
S3Options.forcePathStyle = true; // Needed for LocalStack to avoid bucket name as hostname
}
@@ -102,7 +105,7 @@ const createS3Client = () => {
});
const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 360 });
return presignedUrl;
};
}
return {
uploadFileToS3,
@@ -116,4 +119,7 @@ const createS3Client = () => {
};
};
module.exports = createS3Client();