Compare commits

..

21 Commits

Author SHA1 Message Date
Dave
ca1a456312 feature/IO-3402-Import-Add-Notifiers - Fix Normalize 2025-12-19 14:10:15 -05:00
Dave
c010665ea9 feature/IO-3402-Import-Add-Notifiers - Fix 2025-12-18 18:26:02 -05:00
Dave
d6fba12cd9 feature/IO-3402-Import-Add-Notifiers - Fix Auto Notifiers 2025-12-18 13:27:24 -05:00
Allan Carr
6ea1c291e6 Merged in release/2025-12-19 (pull request #2703)
Release/2025 12 19
2025-12-12 02:36:57 +00:00
Allan Carr
05d5c96491 Merged in feature/IO-3462-Project-Mexico-Mod (pull request #2701)
IO-3462 Project Mexico Mod

Approved-by: Dave Richer
2025-12-10 20:18:14 +00:00
Allan Carr
35a566cbe5 IO-3462 Project Mexico Mod
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-10 09:52:04 -08:00
Dave Richer
f12e40e4c6 Merged in feature/IO-3461-Fix-EULA (pull request #2698)
feature/IO-3461-Fix-Eula
2025-12-09 22:15:22 +00:00
Dave
bb4e671c83 feature/IO-3461-Fix-Eula 2025-12-09 17:13:59 -05:00
Dave Richer
d1637d2432 Merged in release/2025-12-05 (pull request #2696)
Release/2025 12 05 into master-AIO - IO-3450 IO-3452 IO-3262 - IO-3456 IO-3262
2025-12-06 01:48:37 +00:00
Allan Carr
1c79628613 Merged in feature/IO-3262-Tech-Console-Job-Clock-Out (pull request #2692)
IO-3262 Correction for v_year in Project Mexico

Approved-by: Dave Richer
2025-12-05 19:00:38 +00:00
Dave Richer
77268d5f5b Merged in feature/IO-3457-job-lifecyle-tags (pull request #2689)
feature/IO-3457-Job-Lifecycle-Tags
2025-12-04 21:23:27 +00:00
Dave
1b3abf17ec feature/IO-3457-Job-Lifecycle-Tags 2025-12-04 16:22:41 -05:00
Dave Richer
3cfd445894 Merged in feature/IO-3456-Broken-Image-Path (pull request #2687)
feature/IO-3456-Broken-Image - Fix issue
2025-12-04 19:22:03 +00:00
Dave
b510eec9aa feature/IO-3456-Broken-Image - Fix issue 2025-12-04 14:20:58 -05:00
Allan Carr
e5eac0933f Merged in feature/IO-3262-Tech-Console-Job-Clock-Out (pull request #2685)
IO-3262 Add email address to Usage Report

Approved-by: Dave Richer
2025-12-03 19:04:08 +00:00
Allan Carr
78750d3d96 Merged in feature/IO-3262-Tech-Console-Job-Clock-Out (pull request #2682)
IO-3262 Tech Console Job Clock Out

Approved-by: Dave Richer
2025-12-02 18:59:41 +00:00
Allan Carr
065fb72677 Merged in feature/IO-3452-Documents-Adjustments (pull request #2678)
IO-3452 Documents Adjustments

Approved-by: Dave Richer
2025-11-28 15:49:40 +00:00
Allan Carr
ddc6141480 IO-3452 Documents Adjustments
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-11-27 13:15:26 -08:00
Allan Carr
7bc137fa79 Merged in feature/IO-3450-Additional-Crisp-Segments (pull request #2675)
Feature/IO-3450 Additional Crisp Segments

Approved-by: Dave Richer
2025-11-26 16:30:07 +00:00
Allan Carr
dafe9de753 IO-3450 Grammer Correction
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-11-25 18:10:10 -08:00
Allan Carr
78a8474a24 IO-3450 Additional Crisp Segments
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-11-25 17:56:19 -08:00
19 changed files with 289 additions and 167 deletions

View File

@@ -138,7 +138,7 @@ export function App({
);
}
if (currentEula && !currentUser.eulaIsAccepted) {
if (!isPartsEntry && currentEula && !currentUser.eulaIsAccepted) {
return <Eula />;
}

View File

@@ -142,17 +142,37 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
title={t("job_lifecycle.content.legend_title")}
style={{ marginTop: "10px" }}
>
<div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8
}}
>
{lifecycleData.summations.map((key) => (
<Tag key={key.status} color={key.color} style={{ width: "13vh", padding: "4px", margin: "4px" }}>
<Tag
key={key.status}
color={key.color}
style={{
// IMPORTANT: let the tag grow with its content
width: "auto",
padding: 0,
margin: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box"
}}
>
<div
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{
backgroundColor: "var(--tag-wrapper-bg)",
color: "var(--tag-wrapper-text)",
padding: "4px",
textAlign: "center"
padding: "4px 8px",
textAlign: "center",
whiteSpace: "nowrap" // keep it on one line while letting the pill expand
}}
>
{key.status} [{lifecycleData.statusCounts[key.status]}] ({key.roundedPercentage})

View File

@@ -55,7 +55,8 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
const useremail = currentUser.email;
try {
const { ...otherFormValues } = formValues;
// eslint-disable-next-line no-unused-vars
const { accepted_terms, ...otherFormValues } = formValues;
// Trim the values of the fields before submitting
const trimmedFormValues = Object.entries(otherFormValues).reduce((acc, [key, value]) => {

View File

@@ -222,17 +222,37 @@ export function JobLifecycleComponent({ bodyshop, job, statuses }) {
</div>
</BlurWrapperComponent>
<Card type="inner" title={t("job_lifecycle.content.legend_title")} style={{ marginTop: "10px" }}>
<div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8
}}
>
{lifecycleData.durations.summations.map((key) => (
<Tag key={key.status} color={key.color} style={{ width: "13vh", padding: "4px", margin: "4px" }}>
<Tag
key={key.status}
color={key.color}
style={{
// let the tag grow with its content
width: "auto",
padding: 0,
margin: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box"
}}
>
<div
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{
backgroundColor: "var(--tag-wrapper-bg)",
color: "var(--tag-wrapper-text)",
padding: "4px",
textAlign: "center"
padding: "4px 8px",
textAlign: "center",
whiteSpace: "nowrap" // single line; tag gets wider instead of text escaping
}}
>
{key.status} (

View File

@@ -35,16 +35,14 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
...galleryImages.other.filter((image) => image.isSelected)
];
function downloadProgress(progressEvent) {
setDownload((currentDownloadState) => {
return {
downloaded: progressEvent.loaded || 0,
speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0)
};
});
}
const downloadProgress = ({ loaded }) => {
setDownload((currentDownloadState) => ({
downloaded: loaded ?? 0,
speed: (loaded ?? 0) - (currentDownloadState?.downloaded ?? 0)
}));
};
function standardMediaDownload(bufferData) {
const standardMediaDownload = (bufferData) => {
try {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
@@ -55,29 +53,26 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
setLoading(false);
setDownload(null);
}
}
};
const handleDownload = async () => {
logImEXEvent("jobs_documents_download");
setLoading(true);
try {
const response = await axios({
const { data } = await axios({
url: "/media/imgproxy/download",
method: "POST",
responseType: "blob",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
onDownloadProgress: downloadProgress
});
setLoading(false);
setDownload(null);
// Use the response data (Blob) to trigger download
standardMediaDownload(response.data);
standardMediaDownload(data);
} catch {
// handle error (optional)
} finally {
setLoading(false);
setDownload(null);
// handle error (optional)
}
};

View File

@@ -76,14 +76,14 @@ function JobsDocumentsImgproxyComponent({
<SyncOutlined />
</Button>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
<JobsDocumentsDeleteButton
galleryImages={galleryImages}
deletionCallback={billsCallback || fetchThumbnails || refetch}
/>
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
</Space>
</Col>
{!hasMediaAccess && (

View File

@@ -67,7 +67,7 @@ export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, delet
okButtonProps={{ danger: true }}
cancelText={t("general.actions.cancel")}
>
<Button disabled={imagesToDelete.length < 1} loading={loading}>
<Button danger disabled={imagesToDelete.length < 1} loading={loading}>
{t("documents.actions.delete")}
</Button>
</Popconfirm>

View File

@@ -107,8 +107,8 @@ export function JobsDocumentsLocalGallery({
<a href={CreateExplorerLinkForJob({ jobid: job.id })}>
<Button>{t("documents.labels.openinexplorer")}</Button>
</a>
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} />
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsLocalGalleryDownloadButton job={job} />
<JobsDocumentsLocalDeleteButton jobid={job.id} />
</Space>

View File

@@ -28,6 +28,8 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
const [loading, setLoading] = useState(false);
const imagesToDelete = (allMedia?.[jobid] || []).filter((i) => i.isSelected);
const handleDelete = async () => {
logImEXEvent("job_documents_delete");
setLoading(true);
@@ -36,7 +38,7 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
`${bodyshop.localmediaserverhttp}/jobs/delete`,
{
jobid: jobid,
files: (allMedia?.[jobid] || []).filter((i) => i.isSelected).map((i) => i.filename)
files: imagesToDelete.map((i) => i.filename)
},
{ headers: { ims_token: bodyshop.localmediatoken } }
);
@@ -60,14 +62,17 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
return (
<Popconfirm
disabled={imagesToDelete.length < 1}
icon={<QuestionCircleOutlined style={{ color: "red" }} />}
onConfirm={handleDelete}
title={t("documents.labels.confirmdelete")}
okText={t("general.actions.delete")}
okButtonProps={{ type: "danger" }}
okButtonProps={{ danger: true }}
cancelText={t("general.actions.cancel")}
>
<Button loading={loading}>{t("documents.actions.delete")}</Button>
<Button danger disabled={imagesToDelete.length < 1} loading={loading}>
{t("documents.actions.delete")}
</Button>
</Popconfirm>
);
}

View File

@@ -1,8 +1,8 @@
import { Button } from "antd";
import { Button, Space } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectAllMedia } from "../../redux/media/media.selectors";
@@ -19,45 +19,63 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobsLocalGalleryDown
export function JobsLocalGalleryDownloadButton({ bodyshop, allMedia, job }) {
const { t } = useTranslation();
const [download, setDownload] = useState(null);
const [loading, setLoading] = useState(false);
const [download, setDownload] = useState(false);
function downloadProgress(progressEvent) {
setDownload((currentDownloadState) => {
return {
downloaded: progressEvent.loaded || 0,
speed: (progressEvent.loaded || 0) - (currentDownloadState?.downloaded || 0)
};
});
}
const imagesToDownload = (allMedia?.[job.id] || []).filter((i) => i.isSelected);
const downloadProgress = ({ loaded }) => {
setDownload((currentDownloadState) => ({
downloaded: loaded || 0,
speed: (loaded || 0) - (currentDownloadState?.downloaded || 0)
}));
};
const standardMediaDownload = (bufferData, filename) => {
try {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${filename}.zip`;
a.click();
} catch {
setLoading(false);
setDownload(null);
}
};
const handleDownload = async () => {
const theDownloadedZip = await cleanAxios.post(
`${bodyshop.localmediaserverhttp}/jobs/download`,
{
jobid: job.id,
files: (allMedia?.[job.id] || []).filter((i) => i.isSelected).map((i) => i.filename)
},
{
headers: { ims_token: bodyshop.localmediatoken },
responseType: "arraybuffer",
onDownloadProgress: downloadProgress
}
);
setDownload(null);
standardMediaDownload(theDownloadedZip.data, job.ro_number);
const { localmediaserverhttp, localmediatoken } = bodyshop;
const { id, ro_number } = job;
setLoading(true);
try {
const response = await cleanAxios.post(
`${localmediaserverhttp}/jobs/download`,
{
jobid: id,
files: imagesToDownload.map((i) => i.filename)
},
{
headers: { ims_token: localmediatoken },
responseType: "arraybuffer",
onDownloadProgress: downloadProgress
}
);
standardMediaDownload(response.data, ro_number);
} catch {
// handle error (optional)
} finally {
setLoading(false);
setDownload(null);
}
};
return (
<Button loading={!!download} onClick={handleDownload}>
{t("documents.actions.download")}
<Button disabled={imagesToDownload < 1} loading={download || loading} onClick={handleDownload}>
<Space>
<span>{t("documents.actions.download")}</span>
{download && <span>{`(${formatBytes(download.downloaded)} @ ${formatBytes(download.speed)} / second)`}</span>}
</Space>
</Button>
);
}
function standardMediaDownload(bufferData, filename) {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${filename}.zip`;
a.click();
}

View File

@@ -16,6 +16,7 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
{employeeOptions.length > 0 ? (
<Form.Item
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
name="notification_followers"
rules={[
{
@@ -42,11 +43,6 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
options={employeeOptions}
placeholder={t("bodyshop.fields.notifications.placeholder")}
showEmail={true}
onChange={(value) => {
// Filter out null or invalid values before passing to Form
const cleanedValue = value?.filter((id) => id != null && typeof id === "string" && id.trim() !== "");
return cleanedValue;
}}
/>
</Form.Item>
) : (

View File

@@ -50,7 +50,7 @@ import {
} from "./user.actions";
import UserActionTypes from "./user.types";
//import * as amplitude from '@amplitude/analytics-browser';
import posthog from 'posthog-js';
import posthog from "posthog-js";
const fpPromise = FingerprintJS.load();
@@ -269,11 +269,11 @@ export function* signInSuccessSaga({ payload }) {
instanceSeg,
...(isParts
? [
InstanceRenderManager({
imex: "ImexPartsManagement",
rome: "RomePartsManagement"
})
]
InstanceRenderManager({
imex: "ImexPartsManagement",
rome: "RomePartsManagement"
})
]
: [])
];
window.$crisp.push(["set", "session:segments", [segs]]);
@@ -375,17 +375,31 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
const isParts = yield select((state) => state.application.isPartsEntry === true);
const instanceSeg = InstanceRenderManager({ imex: "imex", rome: "rome" });
let featureSegments;
if (payload.features?.allAccess === true) {
featureSegments = ["allAccess"];
} else {
const featureKeys = Object.keys(payload.features).filter(
(key) =>
payload.features[key] === true ||
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
);
featureSegments = ["basic", ...featureKeys];
}
const featureSegments =
payload.features?.allAccess === true
? ["allAccess"]
: [
"basic",
...Object.keys(payload.features).filter(
(key) =>
payload.features[key] === true ||
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
)
];
const additionalSegments = [
payload.cdk_dealerid && "CDK",
payload.pbs_serialnumber && "PBS",
// payload.rr_dealerid && "Reynolds",
payload.accountingconfig.qbo === true && "QBO",
payload.accountingconfig.qbo === false &&
!payload.cdk_dealerid &&
!payload.pbs_serialnumber &&
// !payload.rr_dealerid &&
"QBD"
].filter(Boolean);
featureSegments.push(...additionalSegments);
const regionSeg = payload.region_config ? `region:${payload.region_config}` : null;
const segments = [instanceSeg, ...(regionSeg ? [regionSeg] : []), ...featureSegments];

View File

@@ -1156,7 +1156,11 @@
enable_manual: false
update:
columns:
- imexshopid
- timezone
- shopname
- notification_followers
- state
- md_order_statuses
retry_conf:
interval_sec: 10
@@ -3698,6 +3702,7 @@
- deliverchecklist
- depreciation_taxes
- dms_allocation
- dms_id
- driveable
- employee_body
- employee_csr
@@ -3975,6 +3980,7 @@
- deliverchecklist
- depreciation_taxes
- dms_allocation
- dms_id
- driveable
- employee_body
- employee_csr
@@ -4264,6 +4270,7 @@
- deliverchecklist
- depreciation_taxes
- dms_allocation
- dms_id
- driveable
- employee_body
- employee_csr

View File

@@ -117,44 +117,46 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat
imexshopid: shopid,
json: JSON.stringify(carfaxObject, null, 2),
filename: `${shopid}_${moment().format("DDMMYYYY_HHMMss")}.json`,
count: carfaxObject.job.length
count: carfaxObject?.job?.length || 0
};
if (skipUpload) {
fs.writeFileSync(`./logs/${jsonObj.filename}`, jsonObj.json);
uploadToS3(jsonObj, S3_BUCKET_NAME);
} else {
await uploadViaSFTP(jsonObj);
if (jsonObj.count > 0) {
await uploadViaSFTP(jsonObj);
await sendMexicoBillingEmail({
subject: `${shopid.replace(/_/g, "").toUpperCase()}_MexicoRPS_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`,
text: `Errors:\n${JSON.stringify(
erroredJobs.map((ej) => ({
jobid: ej.job?.id,
error: ej.error
})),
null,
2
)}\n\nUploaded:\n${JSON.stringify(
{
bodyshopid: bodyshop.id,
imexshopid: shopid,
count: jsonObj.count,
filename: jsonObj.filename,
result: jsonObj.result
},
null,
2
)}`
});
await sendMexicoBillingEmail({
subject: `${shopid.replace(/_/g, "").toUpperCase()}_MexicoRPS_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`,
text: `Errors:\n${JSON.stringify(
erroredJobs.map((ej) => ({
jobid: ej.job?.id,
error: ej.error
})),
null,
2
)}\n\nUploaded:\n${JSON.stringify(
{
bodyshopid: bodyshop.id,
imexshopid: shopid,
count: jsonObj.count,
filename: jsonObj.filename,
result: jsonObj.result
},
null,
2
)}`
});
}
}
allJSONResults.push({
jsonObj.count > 0 && allJSONResults.push({
bodyshopid: bodyshop.id,
imexshopid: shopid,
count: jsonObj.count,
filename: jsonObj.filename,
result: jsonObj.result
result: jsonObj.result || "No Upload Result Available"
});
logger.log("CARFAX-RPS-end-shop-extract", "DEBUG", "api", bodyshop.id, {

View File

@@ -160,40 +160,42 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat
imexshopid: shopid,
json: JSON.stringify(carfaxObject, null, 2),
filename: `${shopid}_${moment().format("DDMMYYYY_HHMMss")}.json`,
count: carfaxObject.job.length
count: carfaxObject?.job?.length || 0
};
if (skipUpload) {
fs.writeFileSync(`./logs/${jsonObj.filename}`, jsonObj.json);
uploadToS3(jsonObj);
} else {
await uploadViaSFTP(jsonObj);
if (jsonObj.count > 0) {
await uploadViaSFTP(jsonObj);
await sendMexicoBillingEmail({
subject: `${shopid.replace(/_/g, "").toUpperCase()}_Mexico${InstanceManager({
imex: "IO",
rome: "RO"
})}_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`,
text: `Errors:\n${JSON.stringify(
erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
})),
null,
2
)}\n\nUploaded:\n${JSON.stringify(
{
bodyshopid: bodyshop.id,
imexshopid: shopid,
count: jsonObj.count,
filename: jsonObj.filename,
result: jsonObj.result
},
null,
2
)}`
});
await sendMexicoBillingEmail({
subject: `${shopid.replace(/_/g, "").toUpperCase()}_Mexico${InstanceManager({
imex: "IO",
rome: "RO"
})}_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`,
text: `Errors:\n${JSON.stringify(
erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
})),
null,
2
)}\n\nUploaded:\n${JSON.stringify(
{
bodyshopid: bodyshop.id,
imexshopid: shopid,
count: jsonObj.count,
filename: jsonObj.filename,
result: jsonObj.result
},
null,
2
)}`
});
}
}
allJSONResults.push({
@@ -201,7 +203,7 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat
imexshopid: shopid,
count: jsonObj.count,
filename: jsonObj.filename,
result: jsonObj.result
result: jsonObj.result || "No Upload Result Available"
});
logger.log("CARFAX-end-shop-extract", "DEBUG", "api", bodyshop.id, {

View File

@@ -2926,6 +2926,15 @@ exports.GET_BODYSHOP_BY_ID = `
}
`;
exports.GET_BODYSHOP_WATCHERS_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
notification_followers
}
}
`;
exports.GET_DOCUMENTS_BY_JOB = `
query GET_DOCUMENTS_BY_JOB($jobId: uuid!) {
jobs_by_pk(id: $jobId) {

View File

@@ -134,13 +134,16 @@ const insertUserAssociation = async (uid, email, shopId) => {
/**
* PATCH handler for updating bodyshop fields.
* Allows patching: shopname, address1, address2, city, state, zip_post, country, email, timezone, phone, logo_img_path
* Allows patching: shopname, address1, address2, city, state, zip_post, country, email, timezone, phone
* Also allows updating logo_img_path via a simple logoUrl string, which is expanded to the full object.
* @param req
* @param res
* @returns {Promise<void>}
*/
const patchPartsManagementProvisioning = async (req, res) => {
const { id } = req.params;
// Fields that can be directly patched 1:1
const allowedFields = [
"shopname",
"address1",
@@ -151,31 +154,58 @@ const patchPartsManagementProvisioning = async (req, res) => {
"country",
"email",
"timezone",
"phone",
"logo_img_path"
"phone"
// NOTE: logo_img_path is handled separately via logoUrl
];
const updateFields = {};
// Copy over simple scalar fields if present
for (const field of allowedFields) {
if (req.body[field] !== undefined) {
updateFields[field] = req.body[field];
}
}
// Handle logo update via a simple href string, same behavior as provision route
if (typeof req.body.logo_img_path === "string") {
const trimmed = req.body.logo_img_path.trim();
if (trimmed) {
updateFields.logo_img_path = {
src: trimmed,
width: "",
height: "",
headerMargin: DefaultNewShop.logo_img_path.headerMargin
};
}
}
if (Object.keys(updateFields).length === 0) {
return res.status(400).json({ error: "No valid fields provided for update." });
}
// Check that the bodyshop has an external_shop_id before allowing patch
try {
// Fetch the bodyshop by id
const shopResp = await client.request(
`query GetBodyshop($id: uuid!) { bodyshops_by_pk(id: $id) { id external_shop_id } }`,
`query GetBodyshop($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
external_shop_id
}
}`,
{ id }
);
if (!shopResp.bodyshops_by_pk?.external_shop_id) {
return res.status(400).json({ error: "Cannot patch: bodyshop does not have an external_shop_id." });
}
} catch (err) {
return res.status(500).json({ error: "Failed to validate bodyshop external_shop_id.", detail: err });
return res.status(500).json({
error: "Failed to validate bodyshop external_shop_id.",
detail: err
});
}
try {
const resp = await client.request(UPDATE_BODYSHOP_BY_ID, { id, fields: updateFields });
if (!resp.update_bodyshops_by_pk) {
@@ -211,6 +241,8 @@ const partsManagementProvisioning = async (req, res) => {
"phone",
"userEmail"
]);
// TODO add in check for early access
await ensureExternalIdUnique(body.external_shop_id);
logger.log("admin-create-shop-user", "debug", body.userEmail, null, {

View File

@@ -4,11 +4,14 @@
* This module handles automatically adding watchers to new jobs based on the notifications_autoadd
* boolean field in the associations table and the notification_followers JSON field in the bodyshops table.
* It ensures users are not added twice and logs the process.
*
* NOTE: Bodyshop notification_followers is fetched directly from the DB (Hasura) to avoid stale Redis cache.
*/
const { client: gqlClient } = require("../graphql-client/graphql-client");
const { isEmpty } = require("lodash");
const {
GET_BODYSHOP_WATCHERS_BY_ID,
GET_JOB_WATCHERS_MINIMAL,
GET_NOTIFICATION_WATCHERS,
INSERT_JOB_WATCHERS
@@ -26,10 +29,7 @@ const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "fa
*/
const autoAddWatchers = async (req) => {
const { event, trigger } = req.body;
const {
logger,
sessionUtils: { getBodyshopFromRedis }
} = req;
const { logger } = req;
// Validate that this is an INSERT event, bail
if (trigger?.name !== "notifications_jobs_autoadd" || event.op !== "INSERT" || event.data.old) {
@@ -48,20 +48,20 @@ const autoAddWatchers = async (req) => {
const hasuraUserId = event?.session_variables?.["x-hasura-user-id"];
try {
// Fetch bodyshop data from Redis
const bodyshopData = await getBodyshopFromRedis(shopId);
let notificationFollowers = bodyshopData?.notification_followers;
// Fetch bodyshop data directly from DB (avoid Redis staleness)
const bodyshopResponse = await gqlClient.request(GET_BODYSHOP_WATCHERS_BY_ID, { id: shopId });
const bodyshopData = bodyshopResponse?.bodyshops_by_pk;
// Bail if notification_followers is missing or not an array
if (!notificationFollowers || !Array.isArray(notificationFollowers)) {
return;
}
const notificationFollowersRaw = bodyshopData?.notification_followers;
const notificationFollowers = Array.isArray(notificationFollowersRaw)
? [...new Set(notificationFollowersRaw.filter((id) => id))] // de-dupe + remove falsy
: [];
// Execute queries in parallel
const [notificationData, existingWatchersData] = await Promise.all([
gqlClient.request(GET_NOTIFICATION_WATCHERS, {
shopId,
employeeIds: notificationFollowers.filter((id) => id)
employeeIds: notificationFollowers
}),
gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId })
]);
@@ -73,7 +73,7 @@ const autoAddWatchers = async (req) => {
associationId: assoc.id
})) || [];
// Get users from notification_followers
// Get users from notification_followers (employee IDs -> employee emails)
const followerEmails =
notificationData?.employees
?.filter((e) => e.user_email)
@@ -84,7 +84,7 @@ const autoAddWatchers = async (req) => {
// Combine and deduplicate emails (use email as the unique key)
const usersToAdd = [...autoAddUsers, ...followerEmails].reduce((acc, user) => {
if (!acc.some((u) => u.email === user.email)) {
if (user?.email && !acc.some((u) => u.email === user.email)) {
acc.push(user);
}
return acc;
@@ -123,6 +123,7 @@ const autoAddWatchers = async (req) => {
message: error?.message,
stack: error?.stack,
jobId,
shopId,
roNumber
});
throw error; // Re-throw to ensure the error is logged in the handler

View File

@@ -81,8 +81,8 @@ const alternateTransportChangedBuilder = (data) => {
* @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}}
*/
const billPostedBuilder = (data) => {
const facing = data?.data?.isinhouse ? "in-house" : "vendor";
const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim();
const facing = data?.data?.isinhouse ? "An In House" : "A Vendor";
const body = `${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim();
return buildNotification(data, "notifications.job.billPosted", body, {
isInHouse: data?.data?.isinhouse,