Compare commits

...

24 Commits

Author SHA1 Message Date
Dave
9b44dd844f feature/IO-3487-Auto-Add-Profile-Watchers - Fix Auto Add on a profile level 2025-12-22 14:18:13 -05:00
Dave Richer
4a7bb07345 Merged in hotfix/missing-dms-migration (pull request #2738)
hotfix/missing-dms-migration - Add Back DMS_ID
2025-12-19 20:13:11 +00:00
Dave
01fec9fa79 hotfix/missing-dms-migration - Add Back DMS_ID 2025-12-19 15:12:24 -05:00
Dave Richer
2f88d613c3 Merged in release/2025-12-19-mini (pull request #2732)
Release/2025 12 19 mini - IO-3467 IO-3468 IO-3402 IO-3473
2025-12-19 19:14:30 +00:00
Dave Richer
c9467b3982 Merged in feature/IO-3402-Import-Add-Notifiers (pull request #2734)
feature/IO-3402-Import-Add-Notifiers - Fix Normalize
2025-12-19 19:12:04 +00:00
Dave
ca1a456312 feature/IO-3402-Import-Add-Notifiers - Fix Normalize 2025-12-19 14:10:15 -05:00
Dave
ca4c48bd5c release/2025-12-19-mini - Merge 2025-12-19 12:41:23 -05:00
Dave Richer
e5fd5c8bcb Merged in feature/IO-3468-sentry-exceptions (pull request #2731)
Feature/IO-3468 sentry exceptions
2025-12-19 17:39:23 +00:00
Dave Richer
46945a24a7 Merged in feature/IO-3467-report-center-filter-ui (pull request #2730)
IO-3467 Resolve UI on report center filter.
2025-12-19 17:35:47 +00:00
Dave Richer
be746500a6 Merged in feature/IO-3402-Import-Add-Notifiers (pull request #2729)
Feature/IO-3402 Import Add Notifiers
2025-12-19 17:35:12 +00:00
Dave
71c6d9fa94 IO-3473 trim user input 2025-12-19 12:07:30 -05:00
Dave
c010665ea9 feature/IO-3402-Import-Add-Notifiers - Fix 2025-12-18 18:26:02 -05:00
Dave
6d94ce7e5c feature/IO-3468-Senty-Exceptions - Fix unused import 2025-12-18 13:52:55 -05:00
Dave
d6fba12cd9 feature/IO-3402-Import-Add-Notifiers - Fix Auto Notifiers 2025-12-18 13:27:24 -05:00
Patrick Fic
182a8d59ab IO-3468 Add sentry exceptions & minor nul coalesce fixes. 2025-12-16 10:28:00 -08:00
Patrick Fic
f1847ef650 IO-3467 Resolve UI on report center filter. 2025-12-15 08:37:32 -08: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
Allan Carr
521a7084b7 IO-3262 Correction for v_year in Project Mexico
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-05 09:48:32 -08:00
25 changed files with 336 additions and 166 deletions

View File

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

View File

@@ -253,6 +253,10 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
}
};
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
//Calcualte the new job totals.
const newTotals = (

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

@@ -43,16 +43,18 @@ export function JobCreateIOU({ bodyshop, currentUser, job, selectedJobLines, tec
const handleCreateIou = async () => {
setLoading(true);
//Query all of the job details to recreate.
const iouId = await CreateIouForJob(
client,
job.id,
{
const iouId = await CreateIouForJob({
apolloClient: client,
jobLinesToKeep: selectedJobLines,
jobId: job.id,
config: {
status: bodyshop.md_ro_statuses.default_open,
bodyshopid: bodyshop.id,
useremail: currentUser.email
},
selectedJobLines
);
currentUser
});
notification.open({
type: "success",
message: t("jobs.successes.ioucreated"),

View File

@@ -154,6 +154,10 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
: {})
};
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
if (selectedOwner) {
newJob.ownerid = selectedOwner;
delete newJob.owner;

View File

@@ -175,25 +175,33 @@ export function JobsDetailHeaderActions({
};
const handleDuplicate = () =>
DuplicateJob(
client,
job.id,
{ defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
(newJobId) => {
DuplicateJob({
apolloClient: client,
jobId: job.id,
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
message: t("jobs.successes.duplicated")
});
},
true
);
keepJobLines: true,
currentUser
});
const handleDuplicateConfirm = () =>
DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
message: t("jobs.successes.duplicated")
});
DuplicateJob({
apolloClient: client,
jobId: job.id,
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
message: t("jobs.successes.duplicated")
});
},
keepJobLines: false,
currentUser
});
const handleFinish = async (values) => {
@@ -609,7 +617,7 @@ export function JobsDetailHeaderActions({
<FormDateTimePickerComponent
onBlur={() => {
const start = form.getFieldValue("start");
form.setFieldsValue({ end: start.add(30, "minutes") });
form.setFieldsValue({ end: start?.add(30, "minutes") });
}}
/>
</Form.Item>

View File

@@ -5,7 +5,14 @@ import { INSERT_NEW_JOB, QUERY_JOB_FOR_DUPE } from "../../graphql/jobs.queries";
import dayjs from "../../utils/day";
import i18n from "i18next";
export default async function DuplicateJob(apolloClient, jobId, config, completionCallback, keepJobLines = false) {
export default async function DuplicateJob({
apolloClient,
jobId,
config,
completionCallback,
keepJobLines = false,
currentUser
}) {
logImEXEvent("job_duplicate");
const { defaultOpenStatus } = config;
@@ -19,6 +26,7 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
const existingJob = _.cloneDeep(jobs_by_pk);
delete existingJob.__typename;
delete existingJob.id;
delete existingJob.created_user_email;
delete existingJob.createdat;
delete existingJob.updatedat;
delete existingJob.cieca_stl;
@@ -29,6 +37,10 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
status: defaultOpenStatus
};
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
const _tempLines = _.cloneDeep(existingJob.joblines);
_tempLines.forEach((line) => {
delete line.id;
@@ -55,7 +67,7 @@ export default async function DuplicateJob(apolloClient, jobId, config, completi
return;
}
export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKeep) {
export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToKeep, currentUser }) {
logImEXEvent("job_create_iou");
const { status } = config;
@@ -109,6 +121,9 @@ export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKee
delete newJob.joblines;
newJob.joblines = { data: _tempLines };
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
const res2 = await apolloClient.mutate({
mutation: INSERT_NEW_JOB,
variables: { job: [newJob] }

View File

@@ -144,7 +144,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
<Spin spinning={loading}>
{record[type] ? (
<div>
<span>{`${theEmployee.first_name || ""} ${theEmployee.last_name || ""}`}</span>
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
</div>
) : (

View File

@@ -143,7 +143,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
//TODO: Find a way to filter out / blur on demand.
return (
<div>
<div className="report-center-modal">
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} />
<Form.Item name="defaultSorters" hidden />
@@ -163,13 +163,14 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
{Object.keys(grouped)
//.filter((key) => !groupExcludeKeyFilter.includes(key))
.map((key) => (
<Col md={8} sm={12} key={key}>
<Col xs={24} sm={12} md={Object.keys(grouped).length === 1 ? 24 : 8} key={key}>
<Card.Grid
style={{
width: "100%",
height: "100%",
maxHeight: "33vh",
overflowY: "scroll"
overflowY: "scroll",
minWidth: "200px"
}}
>
<Typography.Title level={4}>{t(`reportcenter.labels.groups.${key}`)}</Typography.Title>
@@ -177,7 +178,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
<BlurWrapperComponent
featureName={groupExcludeKeyFilter.find((g) => g.key === key).featureName}
>
<ul style={{ listStyleType: "none", columns: "2 auto" }}>
<ul style={{ listStyleType: "none", columns: grouped[key].length > 4 ? "2 auto" : "1", padding: 0, margin: 0 }}>
{grouped[key].map((item) => (
<li key={item.key}>
<Radio key={item.key} value={item.key}>
@@ -188,7 +189,7 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
</ul>
</BlurWrapperComponent>
) : (
<ul style={{ listStyleType: "none", columns: "2 auto" }}>
<ul style={{ listStyleType: "none", columns: grouped[key].length > 4 ? "2 auto" : "1", padding: 0, margin: 0 }}>
{grouped[key].map((item) =>
item.featureNameRestricted ? (
<li key={item.key}>

View File

@@ -11,3 +11,38 @@
}
}
}
// Report center modal fixes for column layout
.report-center-modal {
.ant-form-item .ant-radio-group {
width: 100%;
.ant-card-grid {
padding: 16px;
box-sizing: border-box;
ul {
width: 100%;
li {
margin-bottom: 8px;
break-inside: avoid;
page-break-inside: avoid;
.ant-radio-wrapper {
display: flex;
align-items: flex-start;
width: 100%;
span:not(.ant-radio) {
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
flex: 1;
}
}
}
}
}
}
}

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

@@ -5,7 +5,7 @@ import { getFirestore } from "@firebase/firestore";
import { getMessaging, getToken, onMessage } from "@firebase/messaging";
import { store } from "../redux/store";
//import * as amplitude from '@amplitude/analytics-browser';
import posthog from 'posthog-js'
// import posthog from 'posthog-js'
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
initializeApp(config);
@@ -74,7 +74,6 @@ onMessage(messaging, (payload) => {
export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
try {
const state = stateProp || store.getState();
const eventParams = {
@@ -99,8 +98,7 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
// );
logEvent(analytics, eventName, eventParams);
//amplitude.track(eventName, eventParams);
posthog.capture(eventName, eventParams);
//posthog.capture(eventName, eventParams);
} finally {
//If it fails, just keep going.
}

View File

@@ -9,21 +9,23 @@ import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobsCreateComponent from "./jobs-create.component";
import JobCreateContext from "./jobs-create.context";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
});
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
const { t } = useTranslation();
const notification = useNotification();
@@ -74,7 +76,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
}, [t, setBreadcrumbs, setSelectedHeader]);
const runInsertJob = (job) => {
insertJob({ variables: { job: job } })
insertJob({ variables: { job } })
.then((resp) => {
setState({
...state,
@@ -150,6 +152,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
if (job.owner === null) delete job.owner;
if (job.vehicle === null) delete job.vehicle;
// Associate to the current user if one exists
if (currentUser?.email) {
job.created_user_email = currentUser.email;
}
runInsertJob(job);
};

View File

@@ -31,7 +31,8 @@ if (!import.meta.env.DEV) {
"Module specifier, 'fs' does not start",
"Module specifier, 'zlib' does not start with",
"Messaging: This browser doesn't support the API's required to use the Firebase SDK.",
"Failed to update a ServiceWorker for scope"
"Failed to update a ServiceWorker for scope",
"Network Error"
],
integrations: [
// See docs for support of different versions of variation of react router

View File

@@ -24,11 +24,13 @@ const lightningCssTargets = browserslistToTargets(
})
);
const currentDatePST = new Date()
.toLocaleDateString("en-US", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit" })
.split("/")
.reverse()
.join("-");
const pstFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/Los_Angeles",
year: "numeric",
month: "2-digit",
day: "2-digit"
});
const currentDatePST = pstFormatter.format(new Date());
const getFormattedTimestamp = () =>
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");

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
@@ -3680,6 +3684,7 @@
- completed_tasks
- converted
- created_at
- created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -3698,6 +3703,7 @@
- deliverchecklist
- depreciation_taxes
- dms_allocation
- dms_id
- driveable
- employee_body
- employee_csr
@@ -3956,6 +3962,7 @@
- completed_tasks
- converted
- created_at
- created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -3975,6 +3982,7 @@
- deliverchecklist
- depreciation_taxes
- dms_allocation
- dms_id
- driveable
- employee_body
- employee_csr
@@ -4245,6 +4253,7 @@
- completed_tasks
- converted
- created_at
- created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -4264,6 +4273,7 @@
- deliverchecklist
- depreciation_taxes
- dms_allocation
- dms_id
- driveable
- employee_body
- employee_csr
@@ -4634,7 +4644,7 @@
request_transform:
body:
action: transform
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}},\r\n \"created_user_email\": {{$body.event.data.new?.created_user_email}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
method: POST
query_params: {}
template_engine: Kriti

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."jobs" add column "dms_id" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "dms_id" text
null;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."jobs" add column "created_user_email" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "created_user_email" text
null;

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, {
@@ -234,11 +236,10 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const ret = {
ro_number: crypto.createHash("md5").update(job.id, "utf8").digest("hex"),
v_vin: job.v_vin || "",
v_year: job.v_model_yr
? parseInt(job.v_model_yr.match(/\d/g))
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10)
: ""
: "",
v_year: (() => {
const y = parseInt(job.v_model_yr);
return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y;
})(),
v_make: job.v_makedesc || "",
v_model: job.v_model || "",

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, {
@@ -286,11 +288,10 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const ret = {
ro_number: crypto.createHash("md5").update(job.ro_number, "utf8").digest("hex"),
v_vin: job.v_vin || "",
v_year: job.v_model_yr
? parseInt(job.v_model_yr.match(/\d/g))
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10)
: ""
: "",
v_year: (() => {
const y = parseInt(job.v_model_yr);
return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y;
})(),
v_make: job.v_make_desc || "",
v_model: job.v_model_desc || "",

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) {
@@ -3080,17 +3089,19 @@ exports.INSERT_JOB_WATCHERS = `
`;
exports.GET_NOTIFICATION_WATCHERS = `
query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!) {
query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!, $createdUserEmail: String!) {
associations(where: {
_and: [
{ shopid: { _eq: $shopId } },
{ active: { _eq: true } },
{ notifications_autoadd: { _eq: true } }
{ notifications_autoadd: { _eq: true } },
{ useremail: { _eq: $createdUserEmail } }
]
}) {
id
useremail
}
employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) {
user_email
}

View File

@@ -77,9 +77,8 @@ const generateResetLink = async (email) => {
*/
const ensureExternalIdUnique = async (externalId) => {
const resp = await client.request(CHECK_EXTERNAL_SHOP_ID, { key: externalId });
if (resp.bodyshops.length) {
throw { status: 400, message: `external_shop_id '${externalId}' is already in use.` };
}
return !!resp.bodyshops.length;
};
/**
@@ -225,10 +224,25 @@ const patchPartsManagementProvisioning = async (req, res) => {
*/
const partsManagementProvisioning = async (req, res) => {
const { logger } = req;
const body = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() };
// Trim and normalize email early
const body = {
...req.body,
userEmail: req.body.userEmail?.trim().toLowerCase()
};
const trim = (value) => (typeof value === "string" ? value.trim() : value);
const trimIfString = (value) =>
value !== null && value !== undefined && typeof value === "string" ? value.trim() : value;
try {
// Ensure email is present and trimmed before checking registration
if (!body.userEmail) {
throw { status: 400, message: "userEmail is required" };
}
await ensureEmailNotRegistered(body.userEmail);
requireFields(body, [
"external_shop_id",
"shopname",
@@ -241,27 +255,69 @@ const partsManagementProvisioning = async (req, res) => {
"phone",
"userEmail"
]);
await ensureExternalIdUnique(body.external_shop_id);
logger.log("admin-create-shop-user", "debug", body.userEmail, null, {
// Trim all top-level string fields
const trimmedBody = {
...body,
external_shop_id: trim(body.external_shop_id),
shopname: trim(body.shopname),
address1: trim(body.address1),
address2: trimIfString(body.address2),
city: trim(body.city),
state: trim(body.state),
zip_post: trim(body.zip_post),
country: trim(body.country),
email: trim(body.email),
phone: trim(body.phone),
timezone: trimIfString(body.timezone),
logoUrl: trimIfString(body.logoUrl),
userPassword: body.userPassword, // passwords should NOT be trimmed (preserves intentional spaces if any, though rare)
vendors: Array.isArray(body.vendors)
? body.vendors.map((v) => ({
name: trim(v.name),
street1: trimIfString(v.street1),
street2: trimIfString(v.street2),
city: trimIfString(v.city),
state: trimIfString(v.state),
zip: trimIfString(v.zip),
country: trimIfString(v.country),
email: trimIfString(v.email),
cost_center: trimIfString(v.cost_center),
phone: trimIfString(v.phone),
dmsid: trimIfString(v.dmsid),
discount: v.discount ?? 0,
due_date: v.due_date ?? null,
favorite: v.favorite ?? [],
active: v.active ?? true
}))
: []
};
const duplicateCheck = await ensureExternalIdUnique(trimmedBody.external_shop_id);
if (duplicateCheck) {
throw { status: 400, message: `external_shop_id '${trimmedBody.external_shop_id}' is already in use.` };
}
logger.log("admin-create-shop-user", "debug", trimmedBody.userEmail, null, {
request: req.body,
ioadmin: true
});
const shopInput = {
shopname: body.shopname,
address1: body.address1,
address2: body.address2 || null,
city: body.city,
state: body.state,
zip_post: body.zip_post,
country: body.country,
email: body.email,
external_shop_id: body.external_shop_id,
timezone: body.timezone || DefaultNewShop.timezone,
phone: body.phone,
shopname: trimmedBody.shopname,
address1: trimmedBody.address1,
address2: trimmedBody.address2,
city: trimmedBody.city,
state: trimmedBody.state,
zip_post: trimmedBody.zip_post,
country: trimmedBody.country,
email: trimmedBody.email,
external_shop_id: trimmedBody.external_shop_id,
timezone: trimmedBody.timezone || DefaultNewShop.timezone,
phone: trimmedBody.phone,
logo_img_path: {
src: body.logoUrl,
src: trimmedBody.logoUrl || null, // allow empty logo
width: "",
height: "",
headerMargin: DefaultNewShop.logo_img_path.headerMargin
@@ -286,35 +342,37 @@ const partsManagementProvisioning = async (req, res) => {
appt_alt_transport: DefaultNewShop.appt_alt_transport,
md_jobline_presets: DefaultNewShop.md_jobline_presets,
vendors: {
data: body.vendors.map((v) => ({
data: trimmedBody.vendors.map((v) => ({
name: v.name,
street1: v.street1 || null,
street2: v.street2 || null,
city: v.city || null,
state: v.state || null,
zip: v.zip || null,
country: v.country || null,
email: v.email || null,
discount: v.discount ?? 0,
due_date: v.due_date ?? null,
cost_center: v.cost_center || null,
favorite: v.favorite ?? [],
phone: v.phone || null,
active: v.active ?? true,
dmsid: v.dmsid || null
street1: v.street1,
street2: v.street2,
city: v.city,
state: v.state,
zip: v.zip,
country: v.country,
email: v.email,
discount: v.discount,
due_date: v.due_date,
cost_center: v.cost_center,
favorite: v.favorite,
phone: v.phone,
active: v.active,
dmsid: v.dmsid
}))
}
};
const newShopId = await insertBodyshop(shopInput);
const userRecord = await createFirebaseUser(body.userEmail, body.userPassword);
const userRecord = await createFirebaseUser(trimmedBody.userEmail, trimmedBody.userPassword);
let resetLink = null;
if (!body.userPassword) resetLink = await generateResetLink(body.userEmail);
if (!trimmedBody.userPassword) {
resetLink = await generateResetLink(trimmedBody.userEmail);
}
const createdUser = await insertUserAssociation(userRecord.uid, body.userEmail, newShopId);
const createdUser = await insertUserAssociation(userRecord.uid, trimmedBody.userEmail, newShopId);
return res.status(200).json({
shop: { id: newShopId, shopname: body.shopname },
shop: { id: newShopId, shopname: trimmedBody.shopname },
user: {
id: createdUser.id,
email: createdUser.email,
@@ -322,7 +380,7 @@ const partsManagementProvisioning = async (req, res) => {
}
});
} catch (err) {
logger.log("admin-create-shop-user-error", "error", body.userEmail, null, {
logger.log("admin-create-shop-user-error", "error", body.userEmail || "unknown", null, {
message: err.message,
detail: err.detail || err
});

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) {
@@ -39,6 +39,7 @@ const autoAddWatchers = async (req) => {
const jobId = event?.data?.new?.id;
const shopId = event?.data?.new?.shopid;
const roNumber = event?.data?.new?.ro_number || "unknown";
const createdUserEmail = event?.data?.new?.created_user_email || "Unknown";
if (!jobId || !shopId) {
throw new Error(`Missing jobId (${jobId}) or shopId (${shopId}) for auto-add watchers`);
@@ -48,20 +49,21 @@ 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,
createdUserEmail
}),
gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId })
]);
@@ -73,7 +75,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 +86,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 +125,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