Compare commits
21 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fe0cc3bf | ||
|
|
188a7b47b1 | ||
|
|
d08bfc61cd | ||
|
|
503c217c99 | ||
|
|
b444639fca | ||
|
|
6ee7e56b9b | ||
|
|
0340ca5fcc | ||
|
|
1b2fc8b114 | ||
|
|
3745d7a414 | ||
|
|
56559dd3ff | ||
|
|
fde137d7f7 | ||
|
|
b797bf7dc9 | ||
|
|
b87d1a65fe | ||
|
|
019b3cf4da | ||
|
|
05ae0801e5 | ||
|
|
332ade96e5 | ||
|
|
3acec55c0e | ||
|
|
da0462f14c | ||
|
|
2cc9fa961e | ||
|
|
1b6fe4d18e | ||
|
|
22aae0a7f1 |
@@ -13,4 +13,5 @@
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
bodyshop_translations.babel
|
||||
.env.localstack.docker
|
||||
bodyshop_translations.babel
|
||||
|
||||
106
client/package-lock.json
generated
106
client/package-lock.json
generated
@@ -12,6 +12,10 @@
|
||||
"@amplitude/analytics-browser": "^2.34.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
@@ -60,7 +64,6 @@
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.5.4",
|
||||
@@ -2494,6 +2497,73 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/modifiers": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
|
||||
@@ -8396,16 +8466,6 @@
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-runtime": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^2.4.0",
|
||||
"regenerator-runtime": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||
@@ -9192,14 +9252,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
|
||||
@@ -15376,16 +15428,6 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-drag-listview": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-2.0.0.tgz",
|
||||
"integrity": "sha512-7Apx/1Xt4qu+JHHP0rH6aLgZgS7c2MX8ocHVGCi03KfeIWEu0t14MhT3boQKM33l5eJrE/IWfExFTvoYq22fsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"prop-types": "^15.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
||||
@@ -15972,12 +16014,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"@amplitude/analytics-browser": "^2.34.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
@@ -59,7 +63,6 @@
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.5.4",
|
||||
|
||||
@@ -446,3 +446,32 @@
|
||||
//.rbc-time-header-gutter {
|
||||
// padding: 0;
|
||||
//}
|
||||
|
||||
/* globally allow shrink inside table cells */
|
||||
.prod-list-table .ant-table-cell,
|
||||
.prod-list-table .ant-table-cell > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* common AntD offenders */
|
||||
.prod-list-table > .ant-table-cell .ant-space,
|
||||
.ant-table-cell .ant-space-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Keep your custom header content on the left, push AntD sorter to the far right */
|
||||
.prod-list-table .ant-table-column-sorters {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prod-list-table .ant-table-column-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* allows ellipsis to work */
|
||||
}
|
||||
|
||||
.prod-list-table .ant-table-column-sorter {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,13 @@ export function LaborAllocationsAdjustmentEdit({
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(vis) => setOpen(vis)} content={overlay} trigger="click">
|
||||
<Popover
|
||||
getPopupContainer={(trigger) => trigger?.parentElement || document.body}
|
||||
open={open}
|
||||
onOpenChange={(vis) => setOpen(vis)}
|
||||
content={overlay}
|
||||
trigger="click"
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -35,8 +35,6 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
|
||||
const result = await updateJob({
|
||||
variables: { jobId: record.id, job: { [empAssignment]: employeeid } }
|
||||
|
||||
// awaitRefetchQueries: true,
|
||||
});
|
||||
|
||||
insertAuditTrail({
|
||||
@@ -55,6 +53,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
|
||||
await refetch();
|
||||
|
||||
setAssignment({ operation: null, employeeid: null });
|
||||
setLoading(false);
|
||||
};
|
||||
const handleRemove = async (operation) => {
|
||||
@@ -84,6 +83,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
|
||||
await refetch();
|
||||
|
||||
setAssignment({ operation: null, employeeid: null });
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -94,29 +94,31 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
|
||||
const [visibility, setVisibility] = useState(false);
|
||||
const onChange = (e, option) => {
|
||||
setAssignment({ ...assignment, employeeid: e, name: option.name });
|
||||
setAssignment({ ...assignment, employeeid: e, name: option.label });
|
||||
};
|
||||
|
||||
const employeeOptions = bodyshop.employees
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => ({
|
||||
value: emp.id,
|
||||
label: `${emp.first_name} ${emp.last_name}`,
|
||||
name: `${emp.first_name} ${emp.last_name}`
|
||||
}));
|
||||
|
||||
const popContent = (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Select
|
||||
id="employeeSelector"
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
optionFilterProp: "label",
|
||||
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
value={assignment.employeeid}
|
||||
onChange={onChange}
|
||||
>
|
||||
{bodyshop.employees
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={employeeOptions}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Space wrap>
|
||||
@@ -141,25 +143,25 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
|
||||
|
||||
return (
|
||||
<Popover destroyOnHidden content={popContent} open={visibility}>
|
||||
<Spin spinning={loading}>
|
||||
{record[type] ? (
|
||||
<div style={{ cursor: "pointer" }}>
|
||||
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
|
||||
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
|
||||
</div>
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
{record[type] ? (
|
||||
<div style={{ cursor: "pointer" }}>
|
||||
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
|
||||
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
|
||||
</div>
|
||||
) : (
|
||||
<Popover destroyOnHidden content={popContent} open={visibility} trigger="click">
|
||||
<PlusCircleFilled
|
||||
style={{ ...iconStyle, cursor: "pointer" }}
|
||||
className="muted-button"
|
||||
onClick={() => {
|
||||
setAssignment({ operation: type });
|
||||
setAssignment({ operation: type, employeeid: null });
|
||||
setVisibility(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Popover>
|
||||
</Popover>
|
||||
)}
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDragListView from "react-drag-listview";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { closestCenter, DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { selectDarkMode } from "../../redux/application/application.selectors.js";
|
||||
import Prompt from "../../utils/prompt.js";
|
||||
import AlertComponent from "../alert/alert.component.jsx";
|
||||
import ProductionListColumnsAdd from "../production-list-columns/production-list-columns.add.component";
|
||||
@@ -23,12 +27,81 @@ import { logImEXEvent } from "../../firebase/firebase.utils.js";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
technician: selectTechnician,
|
||||
currentUser: selectCurrentUser
|
||||
currentUser: selectCurrentUser,
|
||||
isDarkMode: selectDarkMode
|
||||
});
|
||||
|
||||
export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser }) {
|
||||
// Draggable header cell component - combines drag and resize
|
||||
function DraggableHeaderCell(props) {
|
||||
const { children, columnKey, onResize, width, ...restProps } = props;
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: columnKey,
|
||||
disabled: !columnKey
|
||||
});
|
||||
|
||||
const style = {
|
||||
...restProps.style,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
userSelect: "none",
|
||||
textAlign: "left"
|
||||
};
|
||||
|
||||
// If no columnKey, render as regular header
|
||||
if (!columnKey) {
|
||||
return <ResizeableTitle {...props} />;
|
||||
}
|
||||
|
||||
// Only apply drag listeners to elements with data-drag-handle attribute
|
||||
const filteredListeners = listeners
|
||||
? {
|
||||
onPointerDown: (e) => {
|
||||
// Only trigger drag if clicking on the drag handle
|
||||
if (e.target.closest('[data-drag-handle="true"]')) {
|
||||
listeners.onPointerDown?.(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
: {};
|
||||
|
||||
// Combine drag functionality with resize
|
||||
return (
|
||||
<ResizeableTitle
|
||||
{...restProps}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
onResize={onResize}
|
||||
width={width}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={filteredListeners}
|
||||
>
|
||||
{children}
|
||||
</ResizeableTitle>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser, isDarkMode }) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// NEW: smoother resize
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeRafRef = useRef(null);
|
||||
const pendingResizeRef = useRef(null);
|
||||
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
|
||||
const MIN_COL_WIDTH = 20;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 1
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
treatments: { Production_List_Status_Colors, Enhanced_Payroll }
|
||||
} = useTreatmentsWithConfig({
|
||||
@@ -36,8 +109,10 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
names: ["Production_List_Status_Colors", "Enhanced_Payroll"],
|
||||
splitKey: bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const assoc = bodyshop.associations.find((a) => a.useremail === currentUser.email);
|
||||
const defaultView = assoc?.default_prod_list_view;
|
||||
|
||||
const initialStateRef = useRef(
|
||||
(bodyshop.production_config &&
|
||||
bodyshop.production_config.find((p) => p.name === defaultView)?.columns.tableState) ||
|
||||
@@ -46,6 +121,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
filteredInfo: { text: "" }
|
||||
}
|
||||
);
|
||||
|
||||
const initialColumnsRef = useRef(
|
||||
(initialStateRef.current &&
|
||||
bodyshop?.production_config
|
||||
@@ -66,14 +142,36 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
})) ||
|
||||
[]
|
||||
);
|
||||
|
||||
const [state, setState] = useState(initialStateRef.current);
|
||||
const [columns, setColumns] = useState(initialColumnsRef.current);
|
||||
|
||||
const scrollX = useMemo(() => {
|
||||
// keep scroll width aligned with the actual column widths so AntD doesn't clamp at a fixed floor
|
||||
const sum = columns.reduce((acc, c) => acc + (c.width ?? 100), 0);
|
||||
return Math.max(sum, 1);
|
||||
}, [columns]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const matchingColumnConfig = useMemo(() => {
|
||||
return bodyshop?.production_config?.find((p) => p.name === defaultView);
|
||||
}, [bodyshop.production_config, defaultView]);
|
||||
|
||||
// NEW: cleanup RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// NEW: while resizing, don’t regenerate columns
|
||||
if (isResizing) return;
|
||||
|
||||
// NEW: bail early BEFORE expensive ProductionListColumns(...) call
|
||||
if (!_.isEqual(initialColumnsRef.current, columns)) return;
|
||||
|
||||
const newColumns =
|
||||
matchingColumnConfig?.columns.columnKeys.map((k) => {
|
||||
return {
|
||||
@@ -89,10 +187,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
width: k.width ?? 100
|
||||
};
|
||||
}) || [];
|
||||
// Only update columns if they haven't been manually changed by the user
|
||||
if (_.isEqual(initialColumnsRef.current, columns)) {
|
||||
setColumns(newColumns);
|
||||
}
|
||||
|
||||
setColumns(newColumns);
|
||||
}, [
|
||||
matchingColumnConfig,
|
||||
bodyshop,
|
||||
@@ -102,7 +198,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
Production_List_Status_Colors,
|
||||
refetch,
|
||||
state,
|
||||
columns
|
||||
columns,
|
||||
isResizing
|
||||
]);
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
@@ -118,17 +215,30 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
logImEXEvent("production_list_sort_filter", { pagination, filters, sorter });
|
||||
};
|
||||
|
||||
const onDragEnd = (fromIndex, toIndex) => {
|
||||
if (fromIndex === toIndex) return;
|
||||
const columnsCopy = [...columns];
|
||||
const [movedItem] = columnsCopy.splice(fromIndex, 1);
|
||||
columnsCopy.splice(toIndex, 0, movedItem);
|
||||
if (!_.isEqual(columnsCopy, columns)) {
|
||||
setColumns(columnsCopy);
|
||||
setHasUnsavedChanges(true);
|
||||
const onDragStart = ({ active }) => {
|
||||
setActiveId(active.id);
|
||||
};
|
||||
|
||||
const onDragEnd = ({ active, over }) => {
|
||||
setActiveId(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = columns.findIndex((col) => col.key === active.id);
|
||||
const newIndex = columns.findIndex((col) => col.key === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const newColumns = arrayMove(columns, oldIndex, newIndex);
|
||||
if (!_.isEqual(newColumns, columns)) {
|
||||
setColumns(newColumns);
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDragCancel = () => {
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const removeColumn = (e) => {
|
||||
const { key } = e;
|
||||
const newColumns = columns.filter((i) => i.key !== key);
|
||||
@@ -139,19 +249,55 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
logImEXEvent("production_list_remove_column", { key });
|
||||
};
|
||||
|
||||
const handleResize =
|
||||
(index) =>
|
||||
(e, { size }) => {
|
||||
const nextColumns = [...columns];
|
||||
nextColumns[index] = {
|
||||
...nextColumns[index],
|
||||
width: size.width
|
||||
};
|
||||
if (!_.isEqual(nextColumns, columns)) {
|
||||
setColumns(nextColumns);
|
||||
// NEW: commit widths via rAF (less jank)
|
||||
const applyColumnWidth = useCallback((columnKey, width) => {
|
||||
const nextWidth = Math.max(MIN_COL_WIDTH, Math.round(width));
|
||||
setColumns((prev) => {
|
||||
const idx = prev.findIndex((c) => c.key === columnKey);
|
||||
if (idx === -1) return prev;
|
||||
|
||||
const currentWidth = prev[idx].width ?? 100;
|
||||
if (currentWidth === nextWidth) return prev;
|
||||
|
||||
const next = prev.slice();
|
||||
next[idx] = { ...next[idx], width: nextWidth };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(columnKey) =>
|
||||
(e, { size }) => {
|
||||
pendingResizeRef.current = { columnKey, width: size.width };
|
||||
|
||||
if (resizeRafRef.current) return;
|
||||
resizeRafRef.current = requestAnimationFrame(() => {
|
||||
resizeRafRef.current = null;
|
||||
const pending = pendingResizeRef.current;
|
||||
if (!pending) return;
|
||||
applyColumnWidth(pending.columnKey, pending.width);
|
||||
});
|
||||
},
|
||||
[applyColumnWidth]
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(() => {
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
const handleResizeStop = useCallback(
|
||||
(columnKey) =>
|
||||
(e, { size }) => {
|
||||
setIsResizing(false);
|
||||
|
||||
// Ensure final width is committed
|
||||
applyColumnWidth(columnKey, size.width);
|
||||
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
};
|
||||
logImEXEvent("production_list_resize_column", { key: columnKey, width: size.width });
|
||||
},
|
||||
[applyColumnWidth]
|
||||
);
|
||||
|
||||
const addColumn = (newColumn) => {
|
||||
const updatedColumns = [...columns, newColumn];
|
||||
@@ -163,19 +309,53 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
};
|
||||
|
||||
const headerItem = (col) => {
|
||||
const menu = {
|
||||
onClick: removeColumn,
|
||||
items: [
|
||||
{
|
||||
key: col.key,
|
||||
label: t("production.actions.removecolumn")
|
||||
}
|
||||
]
|
||||
};
|
||||
const menu = { onClick: removeColumn, items: [{ key: col.key, label: t("production.actions.removecolumn") }] };
|
||||
|
||||
return (
|
||||
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
|
||||
<span>{col.title}</span>
|
||||
</Dropdown>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "left",
|
||||
width: "100%",
|
||||
userSelect: "none",
|
||||
minWidth: 0 // critical: allow the flex row to shrink
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="drag-handle-trigger"
|
||||
data-drag-handle="true"
|
||||
style={{
|
||||
marginRight: 8,
|
||||
color: "#999",
|
||||
cursor: "grab",
|
||||
padding: 4,
|
||||
display: "inline-flex",
|
||||
alignItems: "left",
|
||||
userSelect: "none",
|
||||
flex: "0 0 auto"
|
||||
}}
|
||||
title="Drag to reorder column"
|
||||
>
|
||||
<HolderOutlined />
|
||||
</span>
|
||||
|
||||
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
|
||||
<span
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0, // critical: allow text to shrink
|
||||
overflow: "hidden", // clip
|
||||
textOverflow: "ellipsis", // show …
|
||||
whiteSpace: "nowrap", // keep single line
|
||||
cursor: "default",
|
||||
userSelect: "none",
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{col.title}
|
||||
</span>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -274,6 +454,9 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
onSave={() => {
|
||||
setHasUnsavedChanges(false);
|
||||
initialStateRef.current = state;
|
||||
|
||||
// NEW: after saving, treat current columns as the baseline
|
||||
initialColumnsRef.current = columns;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
@@ -286,60 +469,104 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
}
|
||||
/>
|
||||
<ProductionListDetail jobs={dataSource} />
|
||||
<ReactDragListView.DragColumn onDragEnd={onDragEnd} nodeSelector="th" handleSelector=".prod-header-dropdown">
|
||||
<Table
|
||||
sticky
|
||||
pagination={false}
|
||||
size="small"
|
||||
{...(Production_List_Status_Colors.treatment === "on" && {
|
||||
onRow: (record, index) => {
|
||||
if (!bodyshop.md_ro_statuses.production_colors) return null;
|
||||
const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status);
|
||||
if (!color) {
|
||||
if (index % 2 === 0)
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
>
|
||||
<SortableContext items={columns.map((col) => col.key)} strategy={horizontalListSortingStrategy}>
|
||||
<Table
|
||||
sticky
|
||||
tableLayout="fixed"
|
||||
className="prod-list-table"
|
||||
pagination={false}
|
||||
size="small"
|
||||
{...(Production_List_Status_Colors.treatment === "on" &&
|
||||
!isResizing && {
|
||||
onRow: (record, index) => {
|
||||
if (!bodyshop.md_ro_statuses.production_colors) return null;
|
||||
const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status);
|
||||
if (!color) {
|
||||
if (index % 2 === 0)
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: "var(--table-row-even-bg)"
|
||||
}
|
||||
};
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
className: "rowWithColor",
|
||||
style: {
|
||||
backgroundColor: "var(--table-row-even-bg)"
|
||||
"--bgColor": color.color
|
||||
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})`
|
||||
: "var(--status-row-bg-fallback)"
|
||||
}
|
||||
};
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
className: "rowWithColor",
|
||||
style: {
|
||||
"--bgColor": color.color
|
||||
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})`
|
||||
: "var(--status-row-bg-fallback)"
|
||||
}
|
||||
})}
|
||||
components={{
|
||||
header: {
|
||||
cell: DraggableHeaderCell
|
||||
}
|
||||
}}
|
||||
columns={columns.map((c) => {
|
||||
return {
|
||||
...c,
|
||||
filteredValue: state.filteredInfo[c.key] || null,
|
||||
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order,
|
||||
title: headerItem(c),
|
||||
ellipsis: true,
|
||||
width: c.width ?? 100,
|
||||
onHeaderCell: (column) => ({
|
||||
columnKey: column.key,
|
||||
width: column.width,
|
||||
onResize: handleResize(column.key),
|
||||
onResizeStart: handleResizeStart,
|
||||
onResizeStop: handleResizeStop(column.key)
|
||||
})
|
||||
};
|
||||
}
|
||||
})}
|
||||
components={{
|
||||
header: {
|
||||
cell: ResizeableTitle
|
||||
}
|
||||
}}
|
||||
columns={columns.map((c, index) => {
|
||||
return {
|
||||
...c,
|
||||
filteredValue: state.filteredInfo[c.key] || null,
|
||||
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order,
|
||||
title: headerItem(c),
|
||||
ellipsis: true,
|
||||
width: c.width ?? 100,
|
||||
onHeaderCell: (column) => ({
|
||||
width: column.width,
|
||||
onResize: handleResize(index)
|
||||
})
|
||||
};
|
||||
})}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
scroll={{ x: 1000 }}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</ReactDragListView.DragColumn>
|
||||
})}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
scroll={{ x: scrollX }}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeId ? (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: isDarkMode ? "#141414" : "white",
|
||||
color: isDarkMode ? "white" : "#000",
|
||||
border: `2px solid ${isDarkMode ? "#177ddc" : "#1890ff"}`,
|
||||
borderRadius: "4px",
|
||||
padding: "12px 16px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)",
|
||||
cursor: "grabbing",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontWeight: 500,
|
||||
minWidth: "120px"
|
||||
}}
|
||||
>
|
||||
<HolderOutlined style={{ marginRight: "8px", color: isDarkMode ? "white" : "#000", fontSize: "16px" }} />
|
||||
<span>
|
||||
{(() => {
|
||||
const col = columns.find((c) => c.key === activeId);
|
||||
const title = typeof col?.title === "string" ? col.title : col?.dataIndex || col?.key || "Column";
|
||||
return title;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Resizable } from "react-resizable";
|
||||
import "react-resizable/css/styles.css";
|
||||
|
||||
export default function ResizableComponent(props) {
|
||||
const { onResize, width, ...restProps } = props;
|
||||
const ResizableComponent = forwardRef((props, ref) => {
|
||||
const { onResize, onResizeStart, onResizeStop, width, dragAttributes, dragListeners, ...restProps } = props;
|
||||
|
||||
if (!width) {
|
||||
return <th {...restProps} />;
|
||||
return <th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
width={width || 200}
|
||||
width={width}
|
||||
height={0}
|
||||
onResize={onResize}
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeStop={onResizeStop}
|
||||
draggableOpts={{ enableUserSelectHack: false }}
|
||||
handle={
|
||||
resizeHandles={["e"]}
|
||||
axis="x"
|
||||
handle={(axis, handleRef) => (
|
||||
<span
|
||||
className="react-resizable-handle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
ref={handleRef}
|
||||
className={`react-resizable-handle react-resizable-handle-${axis}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<th {...restProps} />
|
||||
<th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />
|
||||
</Resizable>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ResizableComponent.displayName = "ResizableComponent";
|
||||
|
||||
export default ResizableComponent;
|
||||
|
||||
@@ -38,8 +38,6 @@ services:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4001:4000" # Different external port for local access
|
||||
volumes:
|
||||
@@ -65,8 +63,6 @@ services:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4002:4000" # Different external port for local access
|
||||
volumes:
|
||||
@@ -92,8 +88,6 @@ services:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4003:4000" # Different external port for local access
|
||||
volumes:
|
||||
@@ -156,23 +150,18 @@ services:
|
||||
|
||||
# LocalStack
|
||||
localstack:
|
||||
image: localstack/localstack
|
||||
image: localstack/localstack:4.13.1
|
||||
container_name: localstack
|
||||
hostname: localstack
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/...
|
||||
- ./localstack/init:/etc/localstack/init/ready.d:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
|
||||
- DEBUG=0
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
|
||||
- EXTRA_CORS_ALLOWED_ORIGINS=*
|
||||
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
|
||||
env_file:
|
||||
- .env.localstack.docker
|
||||
ports:
|
||||
- "4566:4566"
|
||||
healthcheck:
|
||||
@@ -182,36 +171,6 @@ services:
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
# AWS-CLI
|
||||
aws-cli:
|
||||
image: amazon/aws-cli
|
||||
container_name: aws-cli
|
||||
hostname: aws-cli
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
depends_on:
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- './localstack:/tmp/localstack'
|
||||
- './certs:/tmp/certs'
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
entrypoint: /bin/sh -c
|
||||
command: >
|
||||
"
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
|
||||
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
|
||||
networks:
|
||||
redis-cluster-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -68,23 +68,18 @@ services:
|
||||
# LocalStack: Used to emulate AWS services locally, currently setup for SES
|
||||
# Notes: Set the ENV Debug to 1 for additional logging
|
||||
localstack:
|
||||
image: localstack/localstack
|
||||
image: localstack/localstack:4.13.1
|
||||
container_name: localstack
|
||||
hostname: localstack
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/...
|
||||
- ./localstack/init:/etc/localstack/init/ready.d:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
|
||||
- DEBUG=0
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
|
||||
- EXTRA_CORS_ALLOWED_ORIGINS=*
|
||||
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
|
||||
env_file:
|
||||
- .env.localstack.docker
|
||||
ports:
|
||||
- "4566:4566"
|
||||
healthcheck:
|
||||
@@ -94,38 +89,6 @@ services:
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
# AWS-CLI - Used in conjunction with LocalStack to set required permission to send emails
|
||||
aws-cli:
|
||||
image: amazon/aws-cli
|
||||
container_name: aws-cli
|
||||
hostname: aws-cli
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
depends_on:
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- './localstack:/tmp/localstack'
|
||||
- './certs:/tmp/certs'
|
||||
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
entrypoint: /bin/sh -c
|
||||
command: >
|
||||
"
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
|
||||
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket parts-estimates --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
# Node App: The Main IMEX API
|
||||
node-app:
|
||||
build:
|
||||
@@ -145,8 +108,7 @@ services:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
|
||||
ports:
|
||||
- "4000:4000"
|
||||
- "9229:9229"
|
||||
|
||||
@@ -947,6 +947,7 @@
|
||||
- carfax_exclude
|
||||
- cdk_configuration
|
||||
- cdk_dealerid
|
||||
- chatter_company_id
|
||||
- chatterid
|
||||
- city
|
||||
- claimscorpid
|
||||
@@ -1063,6 +1064,7 @@
|
||||
- bill_allow_post_to_closed
|
||||
- bill_tax_rates
|
||||
- cdk_configuration
|
||||
- chatter_company_id
|
||||
- city
|
||||
- country
|
||||
- created_at
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."bodyshops" add column "chatter_company_id" text
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."bodyshops" add column "chatter_company_id" text
|
||||
null;
|
||||
65
localstack/init/10-bootstrap.sh
Normal file
65
localstack/init/10-bootstrap.sh
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
echo "Running LocalStack bootstrap script: 10-bootstrap.sh"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REGION="${AWS_DEFAULT_REGION:-ca-central-1}"
|
||||
|
||||
# awslocal is the LocalStack wrapper so you don't need --endpoint-url
|
||||
# (it targets the LocalStack gateway automatically)
|
||||
# Docs: https://docs.localstack.cloud/.../aws-cli/
|
||||
ensure_bucket() {
|
||||
local b="$1"
|
||||
if ! awslocal s3api head-bucket --bucket "$b" >/dev/null 2>&1; then
|
||||
awslocal s3api create-bucket \
|
||||
--bucket "$b" \
|
||||
--create-bucket-configuration LocationConstraint="$REGION" \
|
||||
--region "$REGION" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_log_group() {
|
||||
local lg="$1"
|
||||
awslocal logs create-log-group --log-group-name "$lg" --region "$REGION" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
ensure_secret_string() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
|
||||
if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then
|
||||
awslocal secretsmanager update-secret --secret-id "$name" --secret-string "$value" >/dev/null
|
||||
else
|
||||
awslocal secretsmanager create-secret --name "$name" --secret-string "$value" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_secret_file() {
|
||||
local name="$1"
|
||||
local filepath="$2"
|
||||
|
||||
if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then
|
||||
awslocal secretsmanager update-secret --secret-id "$name" --secret-string "file://$filepath" >/dev/null
|
||||
else
|
||||
awslocal secretsmanager create-secret --name "$name" --secret-string "file://$filepath" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# SES identities (idempotent-ish; ignoring if it already exists)
|
||||
awslocal ses verify-domain-identity --domain imex.online --region "$REGION" >/dev/null || true
|
||||
awslocal ses verify-email-identity --email-address noreply@imex.online --region "$REGION" >/dev/null || true
|
||||
|
||||
# Secrets
|
||||
ensure_secret_file "CHATTER_PRIVATE_KEY" "/tmp/certs/io-ftp-test.key"
|
||||
ensure_secret_string "CHATTER_COMPANY_KEY_6713" "${CHATTER_COMPANY_KEY_6713:-REPLACE_ME}"
|
||||
|
||||
# Logs
|
||||
ensure_log_group "development"
|
||||
|
||||
# Buckets
|
||||
ensure_bucket "imex-job-totals"
|
||||
ensure_bucket "parts-estimate"
|
||||
ensure_bucket "imex-large-log"
|
||||
ensure_bucket "imex-carfax-uploads"
|
||||
ensure_bucket "rome-carfax-uploads"
|
||||
ensure_bucket "rps-carfax-uploads"
|
||||
16
server.js
16
server.js
@@ -40,6 +40,8 @@ const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
|
||||
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
|
||||
const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket");
|
||||
const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue");
|
||||
const { loadChatterApiQueue } = require("./server/data/queues/chatterApiQueue");
|
||||
const { processChatterApiJob } = require("./server/data/chatter-api");
|
||||
|
||||
const CLUSTER_RETRY_BASE_DELAY = 100;
|
||||
const CLUSTER_RETRY_MAX_DELAY = 5000;
|
||||
@@ -125,6 +127,7 @@ const applyRoutes = ({ app }) => {
|
||||
app.use("/payroll", require("./server/routes/payrollRoutes"));
|
||||
app.use("/sso", require("./server/routes/ssoRoutes"));
|
||||
app.use("/integrations", require("./server/routes/intergrationRoutes"));
|
||||
app.use("/chatter", require("./server/routes/chatterRoutes"));
|
||||
|
||||
// Default route for forbidden access
|
||||
app.get("/", (req, res) => {
|
||||
@@ -390,6 +393,15 @@ const applySocketIO = async ({ server, app }) => {
|
||||
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
|
||||
|
||||
// Load chatterApi queue with processJob function and redis helpers
|
||||
const chatterApiQueue = await loadChatterApiQueue({
|
||||
pubClient,
|
||||
logger,
|
||||
processJob: processChatterApiJob,
|
||||
getChatterToken: redisHelpers.getChatterToken,
|
||||
setChatterToken: redisHelpers.setChatterToken
|
||||
});
|
||||
|
||||
// Assuming loadEmailQueue and loadAppQueue return Promises
|
||||
const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([
|
||||
loadEmailQueue(queueSettings),
|
||||
@@ -409,6 +421,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||
notificationsFcmQueue.on("error", (error) => {
|
||||
logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
|
||||
});
|
||||
|
||||
chatterApiQueue.on("error", (error) => {
|
||||
logger.log(`Error in chatterApiQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
139
server/chatter/chatter-client.js
Normal file
139
server/chatter/chatter-client.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
|
||||
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: AWS_REGION,
|
||||
credentials: defaultProvider()
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* Chatter API Client for making requests to the Chatter API
|
||||
*/
|
||||
class ChatterApiClient {
|
||||
constructor({ baseUrl, apiToken }) {
|
||||
if (!apiToken) throw new Error("ChatterApiClient requires apiToken");
|
||||
this.baseUrl = String(baseUrl || "").replace(/\/+$/, "");
|
||||
this.apiToken = apiToken;
|
||||
}
|
||||
|
||||
async createLocation(companyId, payload) {
|
||||
return this.request(`/api/v1/companies/${companyId}/locations`, {
|
||||
method: "POST",
|
||||
body: payload
|
||||
});
|
||||
}
|
||||
|
||||
async postInteraction(companyId, payload) {
|
||||
return this.request(`/api/v1/companies/${companyId}/solicitation/interaction`, {
|
||||
method: "POST",
|
||||
body: payload
|
||||
});
|
||||
}
|
||||
|
||||
async request(path, { method = "GET", body } = {}) {
|
||||
const res = await fetch(this.baseUrl + path, {
|
||||
method,
|
||||
headers: {
|
||||
"Api-Token": this.apiToken,
|
||||
Accept: "application/json",
|
||||
...(body ? { "Content-Type": "application/json" } : {})
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const data = text ? safeJson(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error(`Chatter API error ${res.status} | ${data?.message}`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
|
||||
if (retryAfterMs != null) err.retryAfterMs = retryAfterMs;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON, returning original text if parsing fails
|
||||
*/
|
||||
function safeJson(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(value) {
|
||||
if (!value) return null;
|
||||
|
||||
const sec = Number(value);
|
||||
if (Number.isFinite(sec) && sec >= 0) return Math.ceil(sec * 1000);
|
||||
|
||||
const dateMs = Date.parse(value);
|
||||
if (!Number.isFinite(dateMs)) return null;
|
||||
return Math.max(0, dateMs - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Chatter API token from AWS Secrets Manager
|
||||
* SecretId format: CHATTER_COMPANY_KEY_<companyId>
|
||||
*
|
||||
* @param {string|number} companyId - The company ID
|
||||
* @returns {Promise<string>} The API token
|
||||
*/
|
||||
async function getChatterApiToken(companyId) {
|
||||
const key = String(companyId ?? "").trim();
|
||||
if (!key) throw new Error("getChatterApiToken: companyId is required");
|
||||
|
||||
// Optional override for development/testing
|
||||
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
|
||||
|
||||
const secretId = `CHATTER_COMPANY_KEY_${key}`;
|
||||
const command = new GetSecretValueCommand({ SecretId: secretId });
|
||||
const { SecretString, SecretBinary } = await secretsClient.send(command);
|
||||
|
||||
const token =
|
||||
(SecretString && SecretString.trim()) ||
|
||||
(SecretBinary && Buffer.from(SecretBinary, "base64").toString("ascii").trim()) ||
|
||||
"";
|
||||
|
||||
if (!token) throw new Error(`Chatter API token secret is empty: ${secretId}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Chatter API client instance
|
||||
*
|
||||
* @param {string|number} companyId - The company ID
|
||||
* @param {string} [baseUrl] - Optional base URL override
|
||||
* @returns {Promise<ChatterApiClient>} Configured API client
|
||||
*/
|
||||
async function createChatterClient(companyId, baseUrl = CHATTER_BASE_URL) {
|
||||
const apiToken = await getChatterApiToken(companyId);
|
||||
return new ChatterApiClient({ baseUrl, apiToken });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ChatterApiClient,
|
||||
getChatterApiToken,
|
||||
createChatterClient,
|
||||
safeJson,
|
||||
CHATTER_BASE_URL
|
||||
};
|
||||
123
server/chatter/createLocation.js
Normal file
123
server/chatter/createLocation.js
Normal file
@@ -0,0 +1,123 @@
|
||||
const DEFAULT_COMPANY_ID = process.env.CHATTER_DEFAULT_COMPANY_ID;
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const { createChatterClient } = require("./chatter-client");
|
||||
const InstanceManager = require("../utils/instanceMgr").default;
|
||||
|
||||
const GET_BODYSHOP_FOR_CHATTER = `
|
||||
query GET_BODYSHOP_FOR_CHATTER($id: uuid!) {
|
||||
bodyshops_by_pk(id: $id) {
|
||||
id
|
||||
shopname
|
||||
address1
|
||||
city
|
||||
state
|
||||
zip_post
|
||||
imexshopid
|
||||
chatterid
|
||||
chatter_company_id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const UPDATE_BODYSHOP_CHATTER_FIELDS = `
|
||||
mutation UPDATE_BODYSHOP_CHATTER_FIELDS($id: uuid!, $chatter_company_id: String!, $chatterid: String!) {
|
||||
update_bodyshops_by_pk(pk_columns: {id: $id}, _set: {chatter_company_id: $chatter_company_id, chatterid: $chatterid}) {
|
||||
id
|
||||
chatter_company_id
|
||||
chatterid
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createLocation = async (req, res) => {
|
||||
const { logger } = req;
|
||||
const { bodyshopID, googlePlaceID } = req.body;
|
||||
|
||||
console.dir({ body: req.body });
|
||||
|
||||
if (!DEFAULT_COMPANY_ID) {
|
||||
logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID });
|
||||
return res.json({ success: false, message: "No default company set" });
|
||||
}
|
||||
|
||||
if (!googlePlaceID) {
|
||||
logger.log("chatter-create-location-no-google-place-id", "warn", null, null, { bodyshopID });
|
||||
return res.json({ success: false, message: "No google place id provided" });
|
||||
}
|
||||
|
||||
if (!bodyshopID) {
|
||||
logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID });
|
||||
return res.json({ success: false, message: "No bodyshop id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { bodyshops_by_pk: bodyshop } = await client.request(GET_BODYSHOP_FOR_CHATTER, { id: bodyshopID });
|
||||
|
||||
if (!bodyshop) {
|
||||
logger.log("chatter-create-location-bodyshop-not-found", "warn", null, null, { bodyshopID });
|
||||
return res.json({ success: false, message: "Bodyshop not found" });
|
||||
}
|
||||
|
||||
if (bodyshop.chatter_company_id && bodyshop.chatterid) {
|
||||
logger.log("chatter-create-location-already-exists", "warn", null, null, {
|
||||
bodyshopID
|
||||
});
|
||||
return res.json({ success: false, message: "This Bodyshop already has a location associated with it" });
|
||||
}
|
||||
|
||||
const chatterApi = await createChatterClient(DEFAULT_COMPANY_ID);
|
||||
|
||||
const locationIdentifier = `${DEFAULT_COMPANY_ID}-${bodyshop.id}`;
|
||||
|
||||
const locationPayload = {
|
||||
name: bodyshop.shopname,
|
||||
locationIdentifier: locationIdentifier,
|
||||
address: bodyshop.address1,
|
||||
postalCode: bodyshop.zip_post,
|
||||
state: bodyshop.state,
|
||||
city: bodyshop.city,
|
||||
country: InstanceManager({ imex: "Canada", rome: "US" }),
|
||||
googlePlaceId: googlePlaceID,
|
||||
status: "active"
|
||||
};
|
||||
|
||||
logger.log("chatter-create-location-calling-api", "info", null, null, { bodyshopID, locationIdentifier });
|
||||
|
||||
const response = await chatterApi.createLocation(DEFAULT_COMPANY_ID, locationPayload);
|
||||
|
||||
if (!response.location?.id) {
|
||||
logger.log("chatter-create-location-no-location-id", "error", null, null, { bodyshopID, response });
|
||||
return res.json({ success: false, message: "No location ID in response", data: response });
|
||||
}
|
||||
|
||||
await client.request(UPDATE_BODYSHOP_CHATTER_FIELDS, {
|
||||
id: bodyshopID,
|
||||
chatter_company_id: DEFAULT_COMPANY_ID,
|
||||
chatterid: String(response.location.id)
|
||||
});
|
||||
|
||||
logger.log("chatter-create-location-success", "info", null, null, {
|
||||
bodyshopID,
|
||||
chatter_company_id: DEFAULT_COMPANY_ID,
|
||||
chatterid: response.location.id,
|
||||
locationIdentifier
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: response });
|
||||
} catch (error) {
|
||||
logger.log("chatter-create-location-error", "error", null, null, {
|
||||
bodyshopID,
|
||||
error: error.message,
|
||||
status: error.status,
|
||||
data: error.data
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: false,
|
||||
message: error.message || "Failed to create location",
|
||||
error: error.data
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = createLocation;
|
||||
554
server/data/chatter-api.js
Normal file
554
server/data/chatter-api.js
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* Environment variables used by this file
|
||||
* Chatter integration
|
||||
* - CHATTER_API_CONCURRENCY
|
||||
* - Maximum number of jobs/interactions posted concurrently *per shop* (within a single shop's batch).
|
||||
* - Default: 5
|
||||
* - Used by: createConcurrencyLimit(MAX_CONCURRENCY)
|
||||
*
|
||||
* - CHATTER_API_REQUESTS_PER_SECOND
|
||||
* - Per-company outbound request rate (token bucket refill rate).
|
||||
* - Default: 3
|
||||
* - Must be a positive number; otherwise falls back to default.
|
||||
* - Used by: createTokenBucketRateLimiter({ refillPerSecond })
|
||||
*
|
||||
* - CHATTER_API_BURST_CAPACITY
|
||||
* - Per-company token bucket capacity (maximum burst size).
|
||||
* - Default: equals CHATTER_API_REQUESTS_PER_SECOND (i.e., 3 unless overridden)
|
||||
* - Must be a positive number; otherwise falls back to default.
|
||||
* - Used by: createTokenBucketRateLimiter({ capacity })
|
||||
*
|
||||
* - CHATTER_API_MAX_RETRIES
|
||||
* - Maximum number of attempts for posting an interaction before giving up.
|
||||
* - Default: 6
|
||||
* - Must be a positive integer; otherwise falls back to default.
|
||||
* - Used by: postInteractionWithPolicy()
|
||||
*
|
||||
* - CHATTER_API_TOKEN
|
||||
* - Optional override token for emergency/dev scenarios.
|
||||
* - If set, bypasses Secrets Manager/Redis token retrieval and uses this value for all companies.
|
||||
* - Used by: getChatterApiTokenCached()
|
||||
*
|
||||
* Notes
|
||||
* - Per-company API tokens are otherwise fetched via getChatterApiToken(companyId) (Secrets Manager)
|
||||
* and may be cached via `sessionUtils.getChatterToken/setChatterToken` (Redis-backed).
|
||||
*/
|
||||
|
||||
const queries = require("../graphql-client/queries");
|
||||
const moment = require("moment-timezone");
|
||||
const logger = require("../utils/logger");
|
||||
const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client");
|
||||
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
|
||||
const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION";
|
||||
const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5);
|
||||
const CHATTER_REQUESTS_PER_SECOND = getPositiveNumber(process.env.CHATTER_API_REQUESTS_PER_SECOND, 3);
|
||||
const CHATTER_BURST_CAPACITY = getPositiveNumber(process.env.CHATTER_API_BURST_CAPACITY, CHATTER_REQUESTS_PER_SECOND);
|
||||
const CHATTER_MAX_RETRIES = getPositiveInteger(process.env.CHATTER_API_MAX_RETRIES, 6);
|
||||
|
||||
// Client caching (in-memory) - tokens are now cached in Redis
|
||||
const clientCache = new Map(); // companyId -> ChatterApiClient
|
||||
const tokenInFlight = new Map(); // companyId -> Promise<string> (for in-flight deduplication)
|
||||
const companyRateLimiters = new Map(); // companyId -> rate limiter
|
||||
|
||||
/**
|
||||
* Core processing function for Chatter API jobs.
|
||||
* This can be called by the HTTP handler or the BullMQ worker.
|
||||
*
|
||||
* @param {Object} options - Processing options
|
||||
* @param {string} options.start - Start date for the delivery window
|
||||
* @param {string} options.end - End date for the delivery window
|
||||
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process
|
||||
* @param {boolean} options.skipUpload - Dry-run flag
|
||||
* @param {Object} options.sessionUtils - Optional session utils for token caching
|
||||
* @returns {Promise<Object>} Result with totals, allShopSummaries, and allErrors
|
||||
*/
|
||||
async function processChatterApiJob({ start, end, bodyshopIds, skipUpload, sessionUtils }) {
|
||||
logger.log("chatter-api-start", "DEBUG", "api", null, null);
|
||||
|
||||
const allErrors = [];
|
||||
const allShopSummaries = [];
|
||||
|
||||
// Shops that DO have chatter_company_id
|
||||
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS_WITH_COMPANY);
|
||||
|
||||
const shopsToProcess =
|
||||
bodyshopIds?.length > 0 ? bodyshops.filter((shop) => bodyshopIds.includes(shop.id)) : bodyshops;
|
||||
|
||||
logger.log("chatter-api-shopsToProcess-generated", "DEBUG", "api", null, { count: shopsToProcess.length });
|
||||
|
||||
if (shopsToProcess.length === 0) {
|
||||
logger.log("chatter-api-shopsToProcess-empty", "DEBUG", "api", null, null);
|
||||
return {
|
||||
totals: { shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 },
|
||||
allShopSummaries: [],
|
||||
allErrors: []
|
||||
};
|
||||
}
|
||||
|
||||
await processBatchApi({
|
||||
shopsToProcess,
|
||||
start,
|
||||
end,
|
||||
skipUpload,
|
||||
allShopSummaries,
|
||||
allErrors,
|
||||
sessionUtils
|
||||
});
|
||||
|
||||
const totals = allShopSummaries.reduce(
|
||||
(acc, s) => {
|
||||
acc.shops += 1;
|
||||
acc.jobs += s.jobs || 0;
|
||||
acc.sent += s.sent || 0;
|
||||
acc.duplicates += s.duplicates || 0;
|
||||
acc.failed += s.failed || 0;
|
||||
return acc;
|
||||
},
|
||||
{ shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 }
|
||||
);
|
||||
|
||||
logger.log("chatter-api-end", "DEBUG", "api", null, totals);
|
||||
|
||||
return { totals, allShopSummaries, allErrors };
|
||||
}
|
||||
|
||||
exports.default = async (req, res) => {
|
||||
if (process.env.NODE_ENV !== "production") return res.sendStatus(403);
|
||||
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) return res.sendStatus(401);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: "Chatter API job queued for processing",
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const { dispatchChatterApiJob } = require("./queues/chatterApiQueue");
|
||||
const { start, end, bodyshopIds, skipUpload } = req.body;
|
||||
|
||||
await dispatchChatterApiJob({
|
||||
start,
|
||||
end,
|
||||
bodyshopIds,
|
||||
skipUpload
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log("chatter-api-queue-dispatch-error", "ERROR", "api", null, {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.processChatterApiJob = processChatterApiJob;
|
||||
|
||||
async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors, sessionUtils }) {
|
||||
for (const bodyshop of shopsToProcess) {
|
||||
const summary = {
|
||||
bodyshopid: bodyshop.id,
|
||||
imexshopid: bodyshop.imexshopid,
|
||||
shopname: bodyshop.shopname,
|
||||
chatter_company_id: bodyshop.chatter_company_id,
|
||||
chatterid: bodyshop.chatterid,
|
||||
jobs: 0,
|
||||
sent: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
ok: true
|
||||
};
|
||||
|
||||
try {
|
||||
logger.log("chatter-api-start-shop", "DEBUG", "api", bodyshop.id, { shopname: bodyshop.shopname });
|
||||
|
||||
const companyId = parseCompanyId(bodyshop.chatter_company_id);
|
||||
if (!companyId) {
|
||||
summary.ok = false;
|
||||
summary.failed = 0;
|
||||
allErrors.push({
|
||||
...pickShop(bodyshop),
|
||||
fatal: true,
|
||||
errors: [`Invalid chatter_company_id: "${bodyshop.chatter_company_id}"`]
|
||||
});
|
||||
allShopSummaries.push(summary);
|
||||
continue;
|
||||
}
|
||||
|
||||
const chatterApi = await getChatterApiClient(companyId, sessionUtils);
|
||||
|
||||
const { jobs } = await client.request(queries.CHATTER_QUERY, {
|
||||
bodyshopid: bodyshop.id,
|
||||
start: start ? moment(start).startOf("day") : moment().subtract(1, "days").startOf("day"),
|
||||
...(end && { end: moment(end).endOf("day") })
|
||||
});
|
||||
|
||||
summary.jobs = jobs.length;
|
||||
|
||||
// concurrency-limited posting
|
||||
const limit = createConcurrencyLimit(MAX_CONCURRENCY);
|
||||
const results = await Promise.all(
|
||||
jobs.map((j) =>
|
||||
limit(async () => {
|
||||
const payload = buildInteractionPayload(bodyshop, j);
|
||||
|
||||
// keep legacy flag name: skipUpload == dry-run
|
||||
if (skipUpload) return { ok: true, dryRun: true };
|
||||
|
||||
const r = await postInteractionWithPolicy(chatterApi, companyId, payload);
|
||||
return r;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r?.dryRun) continue;
|
||||
if (r?.ok && r?.duplicate) summary.duplicates += 1;
|
||||
else if (r?.ok) summary.sent += 1;
|
||||
else summary.failed += 1;
|
||||
}
|
||||
|
||||
// record failures with some detail (cap to avoid huge emails)
|
||||
const failures = results
|
||||
.filter((r) => r && r.ok === false)
|
||||
.slice(0, 25)
|
||||
.map((r) => ({
|
||||
status: r.status,
|
||||
error: r.error,
|
||||
context: r.context
|
||||
}));
|
||||
|
||||
if (failures.length) {
|
||||
summary.ok = false;
|
||||
allErrors.push({
|
||||
...pickShop(bodyshop),
|
||||
fatal: false,
|
||||
errors: failures
|
||||
});
|
||||
}
|
||||
|
||||
logger.log("chatter-api-end-shop", "DEBUG", "api", bodyshop.id, summary);
|
||||
} catch (error) {
|
||||
summary.ok = false;
|
||||
|
||||
logger.log("chatter-api-error-shop", "ERROR", "api", bodyshop.id, {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
allErrors.push({
|
||||
...pickShop(bodyshop),
|
||||
fatal: true,
|
||||
errors: [error.toString()]
|
||||
});
|
||||
} finally {
|
||||
allShopSummaries.push(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildInteractionPayload(bodyshop, j) {
|
||||
const isCompany = Boolean(j.ownr_co_nm);
|
||||
|
||||
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
|
||||
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
|
||||
|
||||
if (j.actual_delivery && !timestamp) {
|
||||
logger.log("chatter-api-invalid-delivery-timestamp", "WARN", "api", bodyshop.id, {
|
||||
bodyshopId: bodyshop.id,
|
||||
jobId: j.id,
|
||||
timezone: bodyshop.timezone,
|
||||
actualDelivery: j.actual_delivery
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
locationIdentifier: locationIdentifier,
|
||||
event: CHATTER_EVENT,
|
||||
consent: "true",
|
||||
transactionId: j.ro_number != null ? String(j.ro_number) : undefined,
|
||||
timestamp,
|
||||
firstName: isCompany ? null : j.ownr_fn || null,
|
||||
lastName: isCompany ? j.ownr_co_nm : j.ownr_ln || null,
|
||||
emailAddress: j.ownr_ea || undefined,
|
||||
phoneNumber: j.ownr_ph1 || undefined,
|
||||
metadata: {
|
||||
imexShopId: bodyshop.imexshopid,
|
||||
bodyshopId: bodyshop.id,
|
||||
jobId: j.id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function postInteractionWithPolicy(chatterApi, companyId, payload) {
|
||||
const limiter = getCompanyRateLimiter(companyId);
|
||||
const requestContext = {
|
||||
companyId,
|
||||
locationIdentifier: payload?.locationIdentifier,
|
||||
transactionId: payload?.transactionId,
|
||||
timestamp: payload?.timestamp ?? null,
|
||||
bodyshopId: payload?.metadata?.bodyshopId ?? null,
|
||||
jobId: payload?.metadata?.jobId ?? null
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < CHATTER_MAX_RETRIES; attempt++) {
|
||||
await limiter.acquire();
|
||||
|
||||
try {
|
||||
await chatterApi.postInteraction(companyId, payload);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
// duplicate -> treat as successful idempotency outcome
|
||||
if (e.status === 409) return { ok: true, duplicate: true, error: e.data };
|
||||
|
||||
// rate limited -> backoff + retry
|
||||
if (e.status === 429) {
|
||||
const retryDelayMs = retryDelayMsForError(e, attempt);
|
||||
limiter.pause(retryDelayMs);
|
||||
logger.log("chatter-api-request-rate-limited", "WARN", "api", requestContext.bodyshopId, {
|
||||
...requestContext,
|
||||
attempt: attempt + 1,
|
||||
maxAttempts: CHATTER_MAX_RETRIES,
|
||||
status: e.status,
|
||||
retryAfterMs: e.retryAfterMs,
|
||||
retryDelayMs,
|
||||
error: e.data ?? e.message
|
||||
});
|
||||
await sleep(retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
|
||||
...requestContext,
|
||||
attempt: attempt + 1,
|
||||
maxAttempts: CHATTER_MAX_RETRIES,
|
||||
status: e.status,
|
||||
error: e.data ?? e.message
|
||||
});
|
||||
return { ok: false, status: e.status, error: e.data ?? e.message, context: requestContext };
|
||||
}
|
||||
}
|
||||
|
||||
logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
|
||||
...requestContext,
|
||||
maxAttempts: CHATTER_MAX_RETRIES,
|
||||
status: 429,
|
||||
error: "rate limit retry exhausted"
|
||||
});
|
||||
|
||||
return { ok: false, status: 429, error: "rate limit retry exhausted", context: requestContext };
|
||||
}
|
||||
|
||||
function parseCompanyId(val) {
|
||||
const s = String(val ?? "").trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function pickShop(bodyshop) {
|
||||
return {
|
||||
bodyshopid: bodyshop.id,
|
||||
imexshopid: bodyshop.imexshopid,
|
||||
shopname: bodyshop.shopname,
|
||||
chatter_company_id: bodyshop.chatter_company_id,
|
||||
chatterid: bodyshop.chatterid
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function backoffMs(attempt) {
|
||||
const base = Math.min(30_000, 500 * 2 ** attempt);
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
return base + jitter;
|
||||
}
|
||||
|
||||
function retryDelayMsForError(error, attempt) {
|
||||
const retryAfterMs = Number(error?.retryAfterMs);
|
||||
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
return Math.min(60_000, retryAfterMs + jitter);
|
||||
}
|
||||
return backoffMs(attempt);
|
||||
}
|
||||
|
||||
function formatChatterTimestamp(value, timezone) {
|
||||
if (!value) return undefined;
|
||||
|
||||
const hasValidTimezone = Boolean(timezone && moment.tz.zone(timezone));
|
||||
const parsed = hasValidTimezone ? moment(value).tz(timezone) : moment(value);
|
||||
if (!parsed.isValid()) return undefined;
|
||||
|
||||
// Keep a strict, Chatter-friendly timestamp without fractional seconds.
|
||||
return parsed.utc().format("YYYY-MM-DD HH:mm:ss[Z]");
|
||||
}
|
||||
|
||||
function createConcurrencyLimit(max) {
|
||||
let active = 0;
|
||||
const queue = [];
|
||||
|
||||
const next = () => {
|
||||
if (active >= max) return;
|
||||
const fn = queue.shift();
|
||||
if (!fn) return;
|
||||
active++;
|
||||
fn()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
active--;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
return (fn) =>
|
||||
new Promise((resolve, reject) => {
|
||||
queue.push(async () => {
|
||||
try {
|
||||
resolve(await fn());
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function getCompanyRateLimiter(companyId) {
|
||||
const key = String(companyId);
|
||||
const existing = companyRateLimiters.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const limiter = createTokenBucketRateLimiter({
|
||||
refillPerSecond: CHATTER_REQUESTS_PER_SECOND,
|
||||
capacity: CHATTER_BURST_CAPACITY
|
||||
});
|
||||
|
||||
companyRateLimiters.set(key, limiter);
|
||||
return limiter;
|
||||
}
|
||||
|
||||
function createTokenBucketRateLimiter({ refillPerSecond, capacity }) {
|
||||
let tokens = capacity;
|
||||
let lastRefillAt = Date.now();
|
||||
let pauseUntil = 0;
|
||||
let chain = Promise.resolve();
|
||||
|
||||
const refill = () => {
|
||||
const now = Date.now();
|
||||
const elapsedSec = (now - lastRefillAt) / 1000;
|
||||
if (elapsedSec <= 0) return;
|
||||
tokens = Math.min(capacity, tokens + elapsedSec * refillPerSecond);
|
||||
lastRefillAt = now;
|
||||
};
|
||||
|
||||
const waitForPermit = async () => {
|
||||
for (;;) {
|
||||
const now = Date.now();
|
||||
if (pauseUntil > now) {
|
||||
await sleep(pauseUntil - now);
|
||||
continue;
|
||||
}
|
||||
|
||||
refill();
|
||||
if (tokens >= 1) {
|
||||
tokens -= 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = 1 - tokens;
|
||||
const waitMs = Math.max(25, Math.ceil((missing / refillPerSecond) * 1000));
|
||||
await sleep(waitMs);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
acquire() {
|
||||
chain = chain.then(waitForPermit, waitForPermit);
|
||||
return chain;
|
||||
},
|
||||
pause(ms) {
|
||||
const n = Number(ms);
|
||||
if (!Number.isFinite(n) || n <= 0) return;
|
||||
pauseUntil = Math.max(pauseUntil, Date.now() + n);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getPositiveNumber(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
function getPositiveInteger(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a per-company Chatter API client, caching both the token and the client.
|
||||
*/
|
||||
async function getChatterApiClient(companyId, sessionUtils) {
|
||||
const key = String(companyId);
|
||||
|
||||
const existing = clientCache.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const apiToken = await getChatterApiTokenCached(companyId, sessionUtils);
|
||||
const chatterApi = new ChatterApiClient({ baseUrl: CHATTER_BASE_URL, apiToken });
|
||||
|
||||
clientCache.set(key, chatterApi);
|
||||
return chatterApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the per-company token from AWS Secrets Manager with Redis caching
|
||||
* SecretId: CHATTER_COMPANY_KEY_<companyId>
|
||||
*
|
||||
* Uses Redis caching + in-flight dedupe to avoid hammering Secrets Manager.
|
||||
*/
|
||||
async function getChatterApiTokenCached(companyId, sessionUtils) {
|
||||
const key = String(companyId ?? "").trim();
|
||||
if (!key) throw new Error("getChatterApiToken: companyId is required");
|
||||
|
||||
// Optional override for emergency/dev
|
||||
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
|
||||
|
||||
// Check Redis cache if sessionUtils is available
|
||||
if (sessionUtils?.getChatterToken) {
|
||||
const cachedToken = await sessionUtils.getChatterToken(key);
|
||||
if (cachedToken) {
|
||||
logger.log("chatter-api-get-token-cache-hit", "DEBUG", "api", null, { companyId: key });
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for in-flight requests
|
||||
const inflight = tokenInFlight.get(key);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const p = (async () => {
|
||||
logger.log("chatter-api-get-token-cache-miss", "DEBUG", "api", null, { companyId: key });
|
||||
|
||||
// Fetch token from Secrets Manager using shared function
|
||||
const token = await getChatterApiToken(companyId);
|
||||
|
||||
// Store in Redis cache if sessionUtils is available
|
||||
if (sessionUtils?.setChatterToken) {
|
||||
await sessionUtils.setChatterToken(key, token);
|
||||
}
|
||||
|
||||
return token;
|
||||
})();
|
||||
|
||||
tokenInFlight.set(key, p);
|
||||
|
||||
try {
|
||||
return await p;
|
||||
} finally {
|
||||
tokenInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ const converter = require("json-2-csv");
|
||||
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 { isString, isEmpty } = require("lodash");
|
||||
let Client = require("ssh2-sftp-client");
|
||||
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
@@ -144,7 +146,18 @@ async function processBatch(shopsToProcess, start, end, allChatterObjects, allEr
|
||||
|
||||
async function getPrivateKey() {
|
||||
// Connect to AWS Secrets Manager
|
||||
const client = new SecretsManagerClient({ region: "ca-central-1" });
|
||||
const secretsClientOptions = {
|
||||
region: "ca-central-1",
|
||||
credentials: defaultProvider()
|
||||
};
|
||||
|
||||
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);
|
||||
const command = new GetSecretValueCommand({ SecretId: "CHATTER_PRIVATE_KEY" });
|
||||
|
||||
logger.log("chatter-get-private-key", "DEBUG", "api", null, null);
|
||||
|
||||
@@ -9,4 +9,5 @@ exports.emsUpload = require("./emsUpload").default;
|
||||
exports.carfax = require("./carfax").default;
|
||||
exports.carfaxRps = require("./carfax-rps").default;
|
||||
exports.vehicletype = require("./vehicletype/vehicletype").default;
|
||||
exports.documentAnalytics = require("./analytics/documents").default;
|
||||
exports.documentAnalytics = require("./analytics/documents").default;
|
||||
exports.chatterApi = require("./chatter-api").default;
|
||||
|
||||
178
server/data/queues/chatterApiQueue.js
Normal file
178
server/data/queues/chatterApiQueue.js
Normal file
@@ -0,0 +1,178 @@
|
||||
const { Queue, Worker } = require("bullmq");
|
||||
const { registerCleanupTask } = require("../../utils/cleanupManager");
|
||||
const getBullMQPrefix = require("../../utils/getBullMQPrefix");
|
||||
const devDebugLogger = require("../../utils/devDebugLogger");
|
||||
const moment = require("moment-timezone");
|
||||
const { sendServerEmail } = require("../../email/sendemail");
|
||||
|
||||
let chatterApiQueue;
|
||||
let chatterApiWorker;
|
||||
|
||||
/**
|
||||
* Initializes the Chatter API queue and worker.
|
||||
*
|
||||
* @param {Object} options - Configuration options for queue initialization.
|
||||
* @param {Object} options.pubClient - Redis client instance for queue communication.
|
||||
* @param {Object} options.logger - Logger instance for logging events and debugging.
|
||||
* @param {Function} options.processJob - Function to process the Chatter API job.
|
||||
* @param {Function} options.getChatterToken - Function to get Chatter token from Redis.
|
||||
* @param {Function} options.setChatterToken - Function to set Chatter token in Redis.
|
||||
* @returns {Queue} The initialized `chatterApiQueue` instance.
|
||||
*/
|
||||
const loadChatterApiQueue = async ({ pubClient, logger, processJob, getChatterToken, setChatterToken }) => {
|
||||
if (!chatterApiQueue) {
|
||||
const prefix = getBullMQPrefix();
|
||||
|
||||
devDebugLogger(`Initializing Chatter API Queue with prefix: ${prefix}`);
|
||||
|
||||
chatterApiQueue = new Queue("chatterApi", {
|
||||
prefix,
|
||||
connection: pubClient,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 60000 // 1 minute base delay
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chatterApiWorker = new Worker(
|
||||
"chatterApi",
|
||||
async (job) => {
|
||||
const { start, end, bodyshopIds, skipUpload } = job.data;
|
||||
|
||||
logger.log("chatter-api-queue-job-start", "INFO", "api", null, {
|
||||
jobId: job.id,
|
||||
start,
|
||||
end,
|
||||
bodyshopIds,
|
||||
skipUpload
|
||||
});
|
||||
|
||||
try {
|
||||
// Provide sessionUtils-like object with token caching functions
|
||||
const sessionUtils = {
|
||||
getChatterToken,
|
||||
setChatterToken
|
||||
};
|
||||
|
||||
const result = await processJob({
|
||||
start,
|
||||
end,
|
||||
bodyshopIds,
|
||||
skipUpload,
|
||||
sessionUtils
|
||||
});
|
||||
|
||||
logger.log("chatter-api-queue-job-complete", "INFO", "api", null, {
|
||||
jobId: job.id,
|
||||
totals: result.totals
|
||||
});
|
||||
|
||||
// Send email summary
|
||||
await sendServerEmail({
|
||||
subject: `Chatter API Report ${moment().format("MM-DD-YY")}`,
|
||||
text:
|
||||
`Totals:\n${JSON.stringify(result.totals, null, 2)}\n\n` +
|
||||
`Shop summaries:\n${JSON.stringify(result.allShopSummaries, null, 2)}\n\n` +
|
||||
`Errors:\n${JSON.stringify(result.allErrors, null, 2)}\n`
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log("chatter-api-queue-job-error", "ERROR", "api", null, {
|
||||
jobId: job.id,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
// Send error email
|
||||
await sendServerEmail({
|
||||
subject: `Chatter API Error ${moment().format("MM-DD-YY")}`,
|
||||
text: `Job failed:\n${error.message}\n\n${error.stack}`
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
prefix,
|
||||
connection: pubClient,
|
||||
concurrency: 1, // Process one job at a time
|
||||
lockDuration: 14400000 // 4 hours - allow long-running jobs
|
||||
}
|
||||
);
|
||||
|
||||
// Event handlers
|
||||
chatterApiWorker.on("completed", (job) => {
|
||||
devDebugLogger(`Chatter API job ${job.id} completed`);
|
||||
});
|
||||
|
||||
chatterApiWorker.on("failed", (job, err) => {
|
||||
logger.log("chatter-api-queue-job-failed", "ERROR", "api", null, {
|
||||
jobId: job?.id,
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
});
|
||||
});
|
||||
|
||||
chatterApiWorker.on("progress", (job, progress) => {
|
||||
devDebugLogger(`Chatter API job ${job.id} progress: ${progress}%`);
|
||||
});
|
||||
|
||||
// Register cleanup task
|
||||
const shutdown = async () => {
|
||||
devDebugLogger("Closing Chatter API queue worker...");
|
||||
await chatterApiWorker.close();
|
||||
devDebugLogger("Chatter API queue worker closed");
|
||||
};
|
||||
registerCleanupTask(shutdown);
|
||||
}
|
||||
|
||||
return chatterApiQueue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the initialized `chatterApiQueue` instance.
|
||||
*
|
||||
* @returns {Queue} The `chatterApiQueue` instance.
|
||||
* @throws {Error} If `chatterApiQueue` is not initialized.
|
||||
*/
|
||||
const getQueue = () => {
|
||||
if (!chatterApiQueue) {
|
||||
throw new Error("Chatter API queue not initialized. Ensure loadChatterApiQueue is called during bootstrap.");
|
||||
}
|
||||
return chatterApiQueue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatches a Chatter API job to the queue.
|
||||
*
|
||||
* @param {Object} options - Options for the job.
|
||||
* @param {string} options.start - Start date for the delivery window.
|
||||
* @param {string} options.end - End date for the delivery window.
|
||||
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process.
|
||||
* @param {boolean} options.skipUpload - Dry-run flag.
|
||||
* @returns {Promise<void>} Resolves when the job is added to the queue.
|
||||
*/
|
||||
const dispatchChatterApiJob = async ({ start, end, bodyshopIds, skipUpload }) => {
|
||||
const queue = getQueue();
|
||||
|
||||
const jobData = {
|
||||
start: start || moment().subtract(1, "days").startOf("day").toISOString(),
|
||||
end: end || moment().endOf("day").toISOString(),
|
||||
bodyshopIds: bodyshopIds || [],
|
||||
skipUpload: skipUpload || false
|
||||
};
|
||||
|
||||
await queue.add("process-chatter-api", jobData, {
|
||||
jobId: `chatter-api-${moment().format("YYYY-MM-DD-HHmmss")}`
|
||||
});
|
||||
|
||||
devDebugLogger(`Added Chatter API job to queue: ${JSON.stringify(jobData)}`);
|
||||
};
|
||||
|
||||
module.exports = { loadChatterApiQueue, getQueue, dispatchChatterApiJob };
|
||||
@@ -1911,10 +1911,35 @@ exports.GET_AUTOHOUSE_SHOPS = `query GET_AUTOHOUSE_SHOPS {
|
||||
}`;
|
||||
|
||||
exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS {
|
||||
bodyshops(where: {chatterid: {_is_null: false}, _or: {chatterid: {_neq: ""}}}){
|
||||
bodyshops(
|
||||
where: {
|
||||
chatterid: { _is_null: false, _neq: "" }
|
||||
_or: [
|
||||
{ chatter_company_id: { _is_null: true } }
|
||||
{ chatter_company_id: { _eq: "" } }
|
||||
]
|
||||
}
|
||||
) {
|
||||
id
|
||||
shopname
|
||||
chatterid
|
||||
chatter_company_id
|
||||
imexshopid
|
||||
timezone
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.GET_CHATTER_SHOPS_WITH_COMPANY = `query GET_CHATTER_SHOPS_WITH_COMPANY {
|
||||
bodyshops(
|
||||
where: {
|
||||
chatterid: { _is_null: false, _neq: "" }
|
||||
chatter_company_id: { _is_null: false, _neq: "" }
|
||||
}
|
||||
) {
|
||||
id
|
||||
shopname
|
||||
chatterid
|
||||
chatter_company_id
|
||||
imexshopid
|
||||
timezone
|
||||
}
|
||||
|
||||
12
server/routes/chatterRoutes.js
Normal file
12
server/routes/chatterRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require("express");
|
||||
const createLocation = require("../chatter/createLocation");
|
||||
const router = express.Router();
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
|
||||
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
router.use(validateAdminMiddleware);
|
||||
|
||||
router.post("/create-location", createLocation);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +1,21 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium, carfax, carfaxRps } = require("../data/data");
|
||||
const {
|
||||
autohouse,
|
||||
claimscorp,
|
||||
chatter,
|
||||
kaizen,
|
||||
usageReport,
|
||||
podium,
|
||||
carfax,
|
||||
carfaxRps,
|
||||
chatterApi
|
||||
} = require("../data/data");
|
||||
|
||||
router.post("/ah", autohouse);
|
||||
router.post("/cc", claimscorp);
|
||||
router.post("/chatter", chatter);
|
||||
router.post("/chatter-api", chatterApi);
|
||||
router.post("/kaizen", kaizen);
|
||||
router.post("/usagereport", usageReport);
|
||||
router.post("/podium", podium);
|
||||
|
||||
@@ -8,6 +8,12 @@ const client = require("../graphql-client/graphql-client").client;
|
||||
*/
|
||||
const BODYSHOP_CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Chatter API token cache TTL in seconds
|
||||
* @type {number}
|
||||
*/
|
||||
const CHATTER_TOKEN_CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Generate a cache key for a bodyshop
|
||||
* @param bodyshopId
|
||||
@@ -15,6 +21,13 @@ const BODYSHOP_CACHE_TTL = 3600; // 1 hour
|
||||
*/
|
||||
const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`;
|
||||
|
||||
/**
|
||||
* Generate a cache key for a Chatter API token
|
||||
* @param companyId
|
||||
* @returns {`chatter-token:${string}`}
|
||||
*/
|
||||
const getChatterTokenCacheKey = (companyId) => `chatter-token:${companyId}`;
|
||||
|
||||
/**
|
||||
* Generate a cache key for a user socket mapping
|
||||
* @param email
|
||||
@@ -373,9 +386,53 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
*/
|
||||
const getProviderCache = (ns, field) => getSessionData(`${ns}:provider`, field);
|
||||
|
||||
/**
|
||||
* Get Chatter API token from Redis cache
|
||||
* @param companyId
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
const getChatterToken = async (companyId) => {
|
||||
const key = getChatterTokenCacheKey(companyId);
|
||||
try {
|
||||
const token = await pubClient.get(key);
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.log("get-chatter-token-from-redis", "ERROR", "redis", null, {
|
||||
companyId,
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set Chatter API token in Redis cache
|
||||
* @param companyId
|
||||
* @param token
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setChatterToken = async (companyId, token) => {
|
||||
const key = getChatterTokenCacheKey(companyId);
|
||||
try {
|
||||
await pubClient.set(key, token);
|
||||
await pubClient.expire(key, CHATTER_TOKEN_CACHE_TTL);
|
||||
devDebugLogger("chatter-token-cache-set", {
|
||||
companyId,
|
||||
action: "Token cached"
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log("set-chatter-token-in-redis", "ERROR", "redis", null, {
|
||||
companyId,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const api = {
|
||||
getUserSocketMappingKey,
|
||||
getBodyshopCacheKey,
|
||||
getChatterTokenCacheKey,
|
||||
setSessionData,
|
||||
getSessionData,
|
||||
clearSessionData,
|
||||
@@ -390,7 +447,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
getSessionTransactionData,
|
||||
clearSessionTransactionData,
|
||||
setProviderCache,
|
||||
getProviderCache
|
||||
getProviderCache,
|
||||
getChatterToken,
|
||||
setChatterToken
|
||||
};
|
||||
|
||||
Object.assign(module.exports, api);
|
||||
|
||||
Reference in New Issue
Block a user