Compare commits

..

74 Commits

Author SHA1 Message Date
Dave Richer
c4631f50e5 feature/IO-3214-Job-Status-Card-Extension - PR Notes/Package Updates 2025-05-08 11:25:44 -04:00
Dave Richer
110fad2abc feature/IO-3214-Job-Status-Card-Extension - Complete 2025-05-06 17:08:46 -04:00
Dave Richer
b7456cecd4 release/2025-05-09 - Restore GraphQL Dep 2025-05-06 14:55:18 -04:00
Dave Richer
84db1fe81b Merged in feature/IO-3225-Notifications-1.5 (pull request #2300)
Feature/IO-3225 Notifications 1.5 into Release/2025-05-09
2025-05-06 18:20:20 +00:00
Dave Richer
b539111be8 feature/IO-3225-Notifications-1.5: Final Refactoring / Optimization 2025-05-06 14:19:22 -04:00
Dave Richer
8a8bc5a6ed feature/IO-3225-Notifications-1.5: Final Refactoring / Optimization 2025-05-06 13:53:40 -04:00
Dave Richer
020db91105 feature/IO-3225-Notifications-1.5: checkpoint 2025-05-06 13:38:42 -04:00
Dave Richer
1dd28af752 feature/IO-3225-Notifications-1.5: Package Updates / Maintenance 2025-05-06 12:58:21 -04:00
Dave Richer
5ba192eee0 feature/IO-3225-Notifications-1.5: Finish 2025-05-05 17:06:23 -04:00
Dave Richer
8109a12898 feature/IO-3225-Notifications-1.5: DB Changes 2025-05-05 15:02:44 -04:00
Dave Richer
2deb7fd520 feature/IO-3225-Notifications-1.5: DB Changes 2025-05-05 13:57:25 -04:00
Dave Richer
f6cd136679 feature/IO-3225-Notifications-1.5: Packages 2025-05-05 13:16:57 -04:00
Dave Richer
e50cb86296 release/2025-04-25 - Update DD, trying again 2025-05-02 13:45:14 -04:00
Dave Richer
a5a01c44fa release/2025-04-25 - Remove DD 2025-04-30 14:07:01 -04:00
Dave Richer
947e0705e4 release/2025-04-25 - Update/Restore DD for test purposes.4 2025-04-30 11:37:21 -04:00
Dave Richer
aa8a6a837d release/2025-04-25: remove body-parser reference from pay-all.js 2025-04-30 11:30:25 -04:00
Dave Richer
5db440fc9c release/2025-04-25: remove body-parser reference from misc routes 2025-04-30 11:28:15 -04:00
Dave Richer
c299b9376a release/2025-04-25: revert body parser, remove DD 2025-04-30 10:26:38 -04:00
Dave Richer
e5d530ea3e release/2025-04-25: Update Backend Packages 2025-04-28 17:15:13 -04:00
Dave Richer
6da9850946 release/2025-04-25: Update Sentry 2025-04-28 13:03:02 -04:00
Dave Richer
f62609f60c release/2025-04-25: revert body parser to discrete package 2025-04-28 12:58:27 -04:00
Dave Richer
b2d8c66e5b release/2025-04-25: clean 2025-04-28 12:17:40 -04:00
Dave Richer
3c4ed3ba0c release/2025-04-25: Patch Updates to packages 2025-04-26 12:16:46 -04:00
Dave Richer
2e7f827c3f release/2025-04-25: Patch Updates to packages 2025-04-26 11:18:39 -04:00
Patrick Fic
dc82b39dc8 Remove crisp status reporter. 2025-04-25 20:27:07 -07:00
Patrick Fic
bdb741caf8 Merged in feature/IO-3223-canny (pull request #2288)
IO-3223 Add Canny for feature request and change log.

Approved-by: Dave Richer
2025-04-25 21:12:43 +00:00
Dave Richer
f50b198c21 feature/IO-3223-canny - Small syntactic update 2025-04-25 17:12:18 -04:00
Dave Richer
3495326de3 feature/IO-3223-canny - Merge release / fix conflicts 2025-04-25 17:06:44 -04:00
Patrick Fic
b5973085e7 IO-3223 Add Canny for feature request and change log. 2025-04-25 14:02:40 -07:00
Dave Richer
8687214420 release/2025-04-25 - update handleInvoiceBasedPayment.test.js 2025-04-25 11:58:47 -04:00
Dave Richer
d61b89a1e5 release/2025-04-25 - Add logging around handleInvoiceBasePayment paymentResponse, toned logs down. fixed issue in paymentResponseResults 2025-04-25 11:54:36 -04:00
Allan Carr
468b42abd2 Merged in feature/IO-3220-VPB-Popup (pull request #2284)
IO-3220 VPB Board Settings Popup

Approved-by: Dave Richer
2025-04-25 14:59:13 +00:00
Allan Carr
fc03e5f983 IO-3220 VPB Board Settings Popup
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-24 13:32:27 -07:00
Allan Carr
c4742e38ea Merged in feature/IO-3213-Hit-and-Run-Toggle (pull request #2280)
IO-3213 Hit and Run Toggle

Approved-by: Dave Richer
2025-04-24 15:41:41 +00:00
Allan Carr
99e1adbe13 Merge branch 'release/2025-04-25' into feature/IO-3213-Hit-and-Run-Toggle
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>

# Conflicts:
#	client/src/components/jobs-detail-general/jobs-detail-general.component.jsx
2025-04-24 08:42:32 -07:00
Allan Carr
eb5c797a43 Merged in feature/IO-3215-Employee-Assignment-Timeticket-Modal (pull request #2279)
IO-3215 Employee Assignment Timeticket Modal

Approved-by: Dave Richer
2025-04-24 15:34:42 +00:00
Allan Carr
0595c5545e Merged in feature/IO-3212-ACV-Amount (pull request #2281)
IO-3212 ACV Amount

Approved-by: Dave Richer
2025-04-24 15:33:41 +00:00
Allan Carr
55944257aa IO-3212 ACV Amount
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 13:16:49 -07:00
Allan Carr
03241778fa IO-3212 ACV Amount
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 13:10:42 -07:00
Allan Carr
555b81fb14 IO-3213 Hit and Run Toggle
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 12:04:29 -07:00
Allan Carr
a56b720e09 IO-3215 Employee Assignment Timeticket Modal
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 09:51:44 -07:00
Allan Carr
b89eede164 Merged in feature/IO-3164-Schedule-Completion-Business-Days (pull request #2277)
IO-3164 Schedule Completion Business Days

Approved-by: Dave Richer
2025-04-22 13:48:43 +00:00
Allan Carr
c21cc8d6b9 IO-3164 Schedule Completion Business Days
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-21 12:27:33 -07:00
Dave Richer
d02a6bc197 Merged in feature/IO-2282-VSSTA-Integration (pull request #2270)
[DO NOT MERGE] Feature/IO-2282 VSSTA Integration into release/2025-04-25
2025-04-21 17:00:07 +00:00
Dave Richer
360c1ce82d feature/IO-2282-VSSTA-Integration 2025-04-21 12:57:00 -04:00
Allan Carr
a7ef02976c Merged in feature/IO-3200-Extended-Crisp-Segments (pull request #2276)
IO-3200 Extended Crisp Segments for BASIC/LITE

Approved-by: Dave Richer
2025-04-21 16:54:31 +00:00
Allan Carr
6a9e36ea4d IO-3200 Extended Crisp Segments for BASIC/LITE
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-17 16:20:34 -07:00
Allan Carr
5ebca3ff06 Merged in feature/IO-3210-Podium-Datapump (pull request #2275)
IO-3210 Podium Datapump CRON Trigger
2025-04-17 19:42:05 +00:00
Allan Carr
37d4c0a40f IO-3210 Podium Datapump CRON Trigger
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-17 12:42:19 -07:00
Allan Carr
1969a92226 Merged in feature/IO-3210-Podium-Datapump (pull request #2273)
IO-3210 Podium Datapump

Approved-by: Dave Richer
2025-04-17 17:40:59 +00:00
Allan Carr
8840ffc9ba IO-3210 Product Fruits Insurance Company Add Button ID
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-17 10:03:08 -07:00
Allan Carr
19e42ef397 IO-3210 Podium Datapump
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-16 17:52:22 -07:00
Allan Carr
c7eb026986 Merged in feature/IO-3190-Quick-Intake-Schedule-Event (pull request #2271)
IO-3190 Quick Intake Schedule Event

Approved-by: Dave Richer
2025-04-16 20:16:41 +00:00
Allan Carr
5f23f135f2 Merged in feature/IO-3187-Admin-Enhancements (pull request #2269)
IO-3187 Admin Enhancements

Approved-by: Dave Richer
2025-04-16 19:10:03 +00:00
Allan Carr
159ee7364d IO-3187 Admin Enhancements
add BCC

Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-15 22:08:21 -07:00
Dave Richer
aa6ad109c9 feature/IO-3187-Admin-Enhancements - Minor cleanup 2025-04-15 14:21:28 -04:00
Dave Richer
f2a896d568 feature/IO-3187-Admin-Enhancements - Minor cleanup 2025-04-15 14:02:29 -04:00
Dave Richer
546ebba0bd feature/IO-3187-Admin-Enhancements - Minor cleanup 2025-04-15 13:57:50 -04:00
Dave Richer
0e75f54d6e feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup 2025-04-15 13:39:34 -04:00
Dave Richer
30f34a17ea feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup 2025-04-15 13:20:07 -04:00
Dave Richer
6035d94404 feature/IO-2282-VSSTA-Integration: - doc blocks / cleanup 2025-04-15 13:05:42 -04:00
Dave Richer
0b7a23d555 feature/IO-2282-VSSTA-Integration: - include some tests for media utils 2025-04-15 13:02:54 -04:00
Dave Richer
91fe1f4af9 feature/IO-2282-VSSTA-Integration: - Finish Integration 2025-04-15 12:55:38 -04:00
Dave Richer
f09cb7b247 feature/IO-2282-VSSTA-Integration: - Finish Integration 2025-04-15 12:40:33 -04:00
Dave Richer
35a7222f5e feature/IO-2282-VSSTA-Integration: - checkpoint 2025-04-15 11:29:44 -04:00
Dave Richer
d444821cf7 feature/IO-2282-VSSTA-Integration: - checkpoint 2025-04-15 10:46:49 -04:00
Allan Carr
b5cb520944 IO-3187 Admin Enhancements
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-14 17:07:57 -07:00
Allan Carr
6814a3bc33 Merge branch 'master-AIO' into feature/IO-3187-Admin-Enhancements
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-14 16:57:20 -07:00
Patrick Fic
19c2b19abc Merged in feature/IO-3066-partner-refresh (pull request #2268)
IO-3066 Call partner refresh on shop change.
2025-04-14 22:32:44 +00:00
Patrick Fic
22b011139d IO-3066 Call partner refresh on shop change. 2025-04-14 15:29:45 -07:00
Dave Richer
5b30daefe5 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2282-VSSTA-Integration 2025-04-14 11:03:29 -04:00
Dave Richer
e8b9fcbc6e feature/IO-2282-VSSTA-Integration:
- Clean up imgproxy-media.js
2025-04-10 09:37:31 -04:00
Dave Richer
5adf591670 feature/IO-2282-VSSTA-Integration:
- Clean up imgproxy-media.js
2025-04-10 09:27:49 -04:00
Dave Richer
f55764e859 feature/IO-2282-VSSTA-Integration:
- Boilerplate in new route
- Fix issues with imgproxy
- Clean up imgproxy
2025-04-09 14:56:49 -04:00
107 changed files with 8081 additions and 7386 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,8 @@
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.21.1",
"mailparser": "^3.7.1",
"express": "^5.1.0",
"mailparser": "^3.7.2",
"node-fetch": "^3.3.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -74,50 +74,8 @@
})();
</script>
<% } %>
<script>
!(function () {
"use strict";
var e = [
"debug",
"destroy",
"do",
"help",
"identify",
"is",
"off",
"on",
"ready",
"render",
"reset",
"safe",
"set"
];
if (window.noticeable) console.warn("Noticeable SDK code snippet loaded more than once");
else {
var n = (window.noticeable = window.noticeable || []);
<script>!function(w,d,i,s){function l(){if(!d.getElementById(i)){var f=d.getElementsByTagName(s)[0],e=d.createElement(s);e.type="text/javascript",e.async=!0,e.src="https://canny.io/sdk.js",f.parentNode.insertBefore(e,f)}}if("function"!=typeof w.Canny){var c=function(){c.q.push(arguments)};c.q=[],w.Canny=c,"complete"===d.readyState?l():w.attachEvent?w.attachEvent("onload",l):w.addEventListener("load",l,!1)}}(window,document,"canny-jssdk","script");</script>
function t(e) {
return function () {
var t = Array.prototype.slice.call(arguments);
return t.unshift(e), n.push(t), n;
};
}
!(function () {
for (var o = 0; o < e.length; o++) {
var r = e[o];
n[r] = t(r);
}
})(),
(function () {
var e = document.createElement("script");
(e.async = !0), (e.src = "https://sdk.noticeable.io/l.js");
var n = document.head;
n.insertBefore(e, n.firstChild);
})();
}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

2694
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,19 @@
"@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.12",
"@firebase/app": "^0.11.4",
"@firebase/auth": "^1.10.0",
"@firebase/firestore": "^4.7.10",
"@firebase/messaging": "^0.12.17",
"@firebase/analytics": "^0.10.13",
"@firebase/app": "^0.12.1",
"@firebase/auth": "^1.10.2",
"@firebase/firestore": "^4.7.12",
"@firebase/messaging": "^0.12.18",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.1",
"@sentry/cli": "^2.43.0",
"@sentry/react": "^9.11.0",
"@sentry/vite-plugin": "^3.3.1",
"@reduxjs/toolkit": "^2.8.1",
"@sentry/cli": "^2.45.0",
"@sentry/react": "^9.17.0",
"@sentry/vite-plugin": "^3.4.0",
"@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53",
"antd": "^5.24.6",
"antd": "^5.25.0",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0",
"autosize": "^6.0.1",
@@ -37,18 +37,18 @@
"dotenv": "^16.4.7",
"env-cmd": "^10.1.0",
"exifr": "^7.1.3",
"graphql": "^16.10.0",
"graphql": "^16.11.0",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-browser-languagedetector": "^8.1.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.6",
"libphonenumber-js": "^1.12.8",
"logrocket": "^9.0.2",
"markerjs2": "^2.32.4",
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.1",
"object-hash": "^3.0.0",
"prop-types": "^15.8.1",
"query-string": "^9.1.1",
"query-string": "^9.1.2",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.18.0",
@@ -57,7 +57,7 @@
"react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^1.3.4",
"react-grid-layout": "1.3.4",
"react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
@@ -69,7 +69,7 @@
"react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.5",
"react-virtuoso": "^4.12.7",
"recharts": "^2.15.2",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
@@ -79,7 +79,7 @@
"reselect": "^5.1.1",
"sass": "^1.86.3",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.17",
"styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"vite-plugin-ejs": "^1.7.0",
@@ -129,18 +129,18 @@
"devDependencies": {
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.26.3",
"@dotenvx/dotenvx": "^1.39.1",
"@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.43.0",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.24.0",
"@eslint/js": "^9.26.0",
"@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.3.1",
"@sentry/webpack-plugin": "^3.4.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.3.4",
"browserslist": "^4.24.4",
"browserslist": "^4.24.5",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
"eslint": "^8.57.1",
@@ -148,19 +148,19 @@
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"memfs": "^4.17.0",
"memfs": "^4.17.1",
"os-browserify": "^0.3.0",
"playwright": "^1.51.1",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^6.2.5",
"vite-plugin-babel": "^1.3.0",
"vite": "^6.3.5",
"vite-plugin-babel": "^1.3.1",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.1",
"vitest": "^3.1.3",
"workbox-window": "^7.3.0"
}
}

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
const { Option } = Select;
//To be used as a form element only.
const EmployeeSearchSelect = ({ options, ...props }) => {
const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
const { t } = useTranslation();
return (
@@ -21,12 +21,16 @@ const EmployeeSearchSelect = ({ options, ...props }) => {
{options
? options.map((o) => (
<Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
<Space>
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
<Tag color="green">
<Space size="small">
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
{showEmail && o.user_email ? (
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.user_email}
</Tag>
) : null}
</Space>
</Option>
))

View File

@@ -69,12 +69,17 @@ export function ScheduleEventComponent({
variables: { id: event.job.id },
onCompleted: (data) => {
if (data?.jobs_by_pk) {
const totalHours =
(data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) +
(data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0);
form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(),
scheduled_completion: data.jobs_by_pk.scheduled_completion,
actual_completion: data.jobs_by_pk.actual_completion,
scheduled_delivery: data.jobs_by_pk.scheduled_delivery,
actual_delivery: data.jobs_by_pk.actual_delivery
scheduled_completion: data.jobs_by_pk.scheduled_completion
? data.jobs_by_pk.scheduled_completion
: totalHours && bodyshop.ss_configuration.nobusinessdays
? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day")
: dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"),
scheduled_delivery: data.jobs_by_pk.scheduled_delivery
});
}
},

View File

@@ -104,6 +104,7 @@ export default function JobWatcherToggleComponent({
}
placeholder={t("notifications.labels.employee-search")}
value={selectedWatcher}
showEmail={true}
onChange={(value) => {
setSelectedWatcher(value);
handleWatcherSelect(value);

View File

@@ -106,7 +106,12 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
<Form.Item label={t("jobs.fields.date_open")} name="date_open">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_sent_approval")} name="estimate_sent_approval">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_approved")} name="estimate_approved">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.date_scheduled")} name="date_scheduled">
<DateTimePicker />
</Form.Item>

View File

@@ -7,6 +7,7 @@ import { selectJobReadOnly } from "../../redux/application/application.selectors
import { selectBodyshop } from "../../redux/user/user.selectors";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import FormRow from "../layout-form-row/layout-form-row.component";
import dayjs from "../../utils/day";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -40,6 +41,20 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<Form.Item label={t("jobs.fields.date_rentalresp")} name="date_rentalresp">
<DateTimePicker disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_sent_approval")} name="estimate_sent_approval">
<DateTimePicker
disabled={true}
value={job.estimate_sent_approval ? dayjs(job.estimate_sent_approval) : null}
placeholder={t("general.labels.na")}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_approved")} name="estimate_approved">
<DateTimePicker
disabled={true}
value={job.estimate_approved ? dayjs(job.estimate_approved) : null}
placeholder={t("general.labels.na")}
/>
</Form.Item>
</FormRow>
<FormRow header={t("jobs.forms.scheddates")}>
@@ -76,21 +91,15 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<DateTimePicker disabled={jobRO} />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
<Form.Item
label={t("jobs.fields.actual_completion")}
name="actual_completion"
rules={[
{
required: jobInPostProduction
}
]}
>
<DateTimePicker disabled={jobRO} />
</Form.Item>
);
}}
{() => (
<Form.Item
label={t("jobs.fields.actual_completion")}
name="actual_completion"
rules={[{ required: jobInPostProduction }]}
>
<DateTimePicker disabled={jobRO} />
</Form.Item>
)}
</Form.Item>
<Form.Item label={t("jobs.fields.scheduled_delivery")} name="scheduled_delivery">
<DateTimePicker disabled={jobRO} />
@@ -103,15 +112,12 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<Form.Item label={t("jobs.fields.date_invoiced")} name="date_invoiced">
<DateTimePicker disabled={true || jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.date_exported")} name="date_exported">
<DateTimePicker disabled={true || jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.date_void")} name="date_void">
<DateTimePicker disabled={true || jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.date_lost_sale")} name="date_lost_sale">
<DateTimePicker disabled={true || jobRO} />
</Form.Item>

View File

@@ -1,5 +1,4 @@
import { Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -188,6 +187,12 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Form.Item label={t("jobs.fields.tlos_ind")} name="tlos_ind" valuePropName="checked">
<Switch disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.hit_and_run")} name="hit_and_run" valuePropName="checked">
<Switch disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.acv_amount")} name="acv_amount">
<CurrencyInput disabled={jobRO} min={0} />
</Form.Item>
</FormRow>
</Col>
<Col {...lossColDamage}>

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
@@ -12,7 +13,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
const mapStateToProps = createStructuredSelector({
@@ -44,9 +44,16 @@ export function JobsDetailHeaderActionsToggleProduction({
variables: { id: job.id },
onCompleted: (data) => {
if (data?.jobs_by_pk) {
const totalHours =
(data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) +
(data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0);
form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(),
scheduled_completion: data.jobs_by_pk.scheduled_completion,
scheduled_completion: data.jobs_by_pk.scheduled_completion
? data.jobs_by_pk.scheduled_completion
: totalHours && bodyshop.ss_configuration.nobusinessdays
? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day")
: dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"),
actual_completion: data.jobs_by_pk.actual_completion,
scheduled_delivery: data.jobs_by_pk.scheduled_delivery,
actual_delivery: data.jobs_by_pk.actual_delivery

View File

@@ -1,15 +1,18 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons";
import { Card, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import React, { useState } from "react";
import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { useMutation } from "@apollo/client";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.component";
@@ -21,7 +24,7 @@ import ProductionListColumnComment from "../production-list-columns/production-l
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
import "./jobs-detail-header.styles.scss";
import dayjs from "../../utils/day";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -29,41 +32,55 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" }))
setPrintCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "printCenter"
})
)
});
const colSpan = {
xs: {
span: 24
},
sm: {
span: 24
},
md: {
span: 12
},
lg: {
span: 6
},
xl: {
span: 6
}
xs: { span: 24 },
sm: { span: 24 },
md: { span: 12 },
lg: { span: 6 },
xl: { span: 6 }
};
export function JobsDetailHeader({ job, bodyshop, disabled }) {
const { t } = useTranslation();
const { notification } = useNotification();
const [notesClamped, setNotesClamped] = useState(true);
const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""}
${job.v_make_desc || ""}
${job.v_model_desc || ""}`.trim();
const [updateJob] = useMutation(UPDATE_JOB);
const vehicleTitle =
`${job.v_model_yr || ""} ${job.v_color || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim();
const bodyHrs = job.joblines.filter((j) => j.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const refinishHrs = job.joblines
.filter((line) => line.mod_lbr_ty === "LAR")
.reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const ownerTitle = OwnerNameDisplayFunction(job).trim();
// Handle checkbox changes
const handleCheckboxChange = async (field, checked) => {
const value = checked ? dayjs().toISOString() : null;
try {
await updateJob({
variables: {
jobId: job.id,
job: { [field]: value }
},
refetchQueries: ["GET_JOB_BY_PK"],
awaitRefetchQueries: true
});
} catch (error) {
notification.error({
message: t("jobs.errors.saving", { error: error.message })
});
}
};
return (
<Row gutter={[16, 16]} style={{ alignItems: "stretch" }}>
<Col {...colSpan}>
@@ -72,11 +89,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<DataLabel label={t("jobs.fields.status")}>
<Space wrap>
{job.status}
{job.inproduction && (
<Tag color="#f50" key="production">
{t("jobs.labels.inproduction")}
</Tag>
)}
{job.inproduction && <Tag color="#f50">{t("jobs.labels.inproduction")}</Tag>}
{job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{job.iouparent && (
<Link to={`/manage/jobs/${job.iouparent}`}>
@@ -110,7 +123,6 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
<DataLabel label={t("jobs.fields.alt_transport")}>
{job.alt_transport}
<JobAltTransportChange job={job} />
@@ -127,11 +139,39 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
))}
</DataLabel>
)}
<DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} />
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_sent_approval")}>
<Space>
<Checkbox
checked={!!job.estimate_sent_approval}
onChange={(e) => handleCheckboxChange("estimate_sent_approval", e.target.checked)}
disabled={disabled}
>
{job.estimate_sent_approval && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_sent_approval}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_approved")}>
<Space>
<Checkbox
checked={!!job.estimate_approved}
onChange={(e) => handleCheckboxChange("estimate_approved", e.target.checked)}
disabled={disabled}
>
{job.estimate_approved && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_approved}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<Space wrap>
{job.special_coverage_policy && (
<Tag color="tomato">
@@ -149,6 +189,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
</Space>
</Tag>
)}
{job.hit_and_run && (
<Tag color="green">
<Space>
<WarningFilled />
<span>{t("jobs.fields.hit_and_run")}</span>
</Space>
</Tag>
)}
</Space>
</div>
</Card>

View File

@@ -1,12 +1,10 @@
import { Button, Space } from "antd";
import axios from "axios";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
//import yauzl from "yauzl";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -28,7 +26,7 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton);
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier }) {
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier, jobId }) {
const { t } = useTranslation();
const [download, setDownload] = useState(null);
const [loading, setLoading] = useState(false);
@@ -46,6 +44,7 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
};
});
}
function standardMediaDownload(bufferData) {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
@@ -53,13 +52,14 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
a.download = `${identifier || "documents"}.zip`;
a.click();
}
const handleDownload = async () => {
logImEXEvent("jobs_documents_download");
setLoading(true);
const zipUrl = await axios({
url: "/media/imgproxy/download",
method: "POST",
data: { documentids: imagesToDownload.map((_) => _.id) }
data: { jobId, documentids: imagesToDownload.map((_) => _.id) }
});
const theDownloadedZip = await cleanAxios({

View File

@@ -75,7 +75,7 @@ function JobsDocumentsImgproxyComponent({
<SyncOutlined />
</Button>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} />
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
<JobsDocumentsDeleteButton
galleryImages={galleryImages}
deletionCallback={billsCallback || fetchThumbnails || refetch}

View File

@@ -1,12 +1,16 @@
import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Space, Table } from "antd";
import { Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js";
import {
QUERY_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATIONS_AUTOADD
} from "../../graphql/user.queries.js";
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types";
@@ -24,9 +28,11 @@ const NotificationSettingsForm = ({ currentUser }) => {
const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({});
const [isDirty, setIsDirty] = useState(false);
const [autoAddEnabled, setAutoAddEnabled] = useState(false);
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification();
// Fetch notification settings.
// Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
@@ -34,13 +40,16 @@ const NotificationSettingsForm = ({ currentUser }) => {
skip: !currentUser
});
const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
const [updateNotificationSettings, { loading: savingSettings }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
const [updateNotificationsAutoAdd, { loading: savingAutoAdd }] = useMutation(UPDATE_NOTIFICATIONS_AUTOADD);
// Populate form with fetched data.
// Populate form with fetched data
useEffect(() => {
if (data?.associations?.length > 0) {
const settings = data.associations[0].notification_settings || {};
// Ensure each scenario has an object with { app, email, fcm }.
const autoAdd = data.associations[0].notifications_autoadd ?? false;
// Ensure each scenario has an object with { app, email, fcm }
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
return acc;
@@ -48,32 +57,66 @@ const NotificationSettingsForm = ({ currentUser }) => {
setInitialValues(formattedValues);
form.setFieldsValue(formattedValues);
setIsDirty(false); // Reset dirty state when new data loads.
setAutoAddEnabled(autoAdd);
setInitialAutoAdd(autoAdd);
setIsDirty(false); // Reset dirty state when new data loads
}
}, [data, form]);
// Handle toggle of notifications_autoadd
const handleAutoAddToggle = async (checked) => {
if (data?.associations?.length > 0) {
const userId = data.associations[0].id;
try {
const result = await updateNotificationsAutoAdd({
variables: { id: userId, autoadd: checked }
});
if (!result?.errors) {
setAutoAddEnabled(checked);
setInitialAutoAdd(checked);
notification.success({ message: t("notifications.labels.auto-add-success") });
setIsDirty(false); // Reset dirty state if only auto-add was changed
} else {
throw new Error("Failed to update auto-add setting");
}
} catch (err) {
setAutoAddEnabled(!checked); // Revert on error
notification.error({ message: t("notifications.labels.auto-add-failure") });
}
}
};
// Handle save of notification settings
const handleSave = async (values) => {
if (data?.associations?.length > 0) {
const userId = data.associations[0].id;
// Save the updated notification settings.
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
if (!result?.errors) {
notification.success({ message: t("notifications.labels.notification-settings-success") });
setInitialValues(values);
setIsDirty(false);
} else {
try {
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
if (!result?.errors) {
notification.success({ message: t("notifications.labels.notification-settings-success") });
setInitialValues(values);
setIsDirty(false);
} else {
throw new Error("Failed to update notification settings");
}
} catch (err) {
notification.error({ message: t("notifications.labels.notification-settings-failure") });
}
}
};
// Mark the form as dirty on any manual change.
// Mark the form as dirty on any manual change
const handleFormChange = () => {
setIsDirty(true);
};
// Check if auto-add has changed
const isAutoAddDirty = autoAddEnabled !== initialAutoAdd;
// Handle reset of form and auto-add
const handleReset = () => {
form.setFieldsValue(initialValues);
setAutoAddEnabled(initialAutoAdd);
setIsDirty(false);
};
@@ -139,17 +182,25 @@ const NotificationSettingsForm = ({ currentUser }) => {
title={t("notifications.labels.notificationscenarios")}
extra={
<Space>
<Button type="default" onClick={handleReset} disabled={!isDirty}>
<Typography.Text type="secondary">{t("notifications.labels.auto-add")}</Typography.Text>
<Switch
checked={autoAddEnabled}
onChange={handleAutoAddToggle}
loading={savingAutoAdd}
// checkedChildren={t("notifications.labels.auto-add-on")}
// unCheckedChildren={t("notifications.labels.auto-add-off")}
/>
<Button type="default" onClick={handleReset} disabled={!isDirty && !isAutoAddDirty}>
{t("general.actions.clear")}
</Button>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={savingSettings}>
{t("notifications.labels.save")}
</Button>
</Space>
}
>
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
<Divider />
</Card>
</Form>
);

View File

@@ -1,7 +1,6 @@
import React from "react";
import { Card, Form, Select } from "antd";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const FilterSettings = ({
selectedMdInsCos,

View File

@@ -1,10 +1,9 @@
import { Card, Checkbox, Col, Form, Row } from "antd";
import React from "react";
import PropTypes from "prop-types";
const InformationSettings = ({ t }) => (
<Card title={t("production.settings.information")}>
<Row gutter={[16, 16]}>
<Card title={t("production.settings.information")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]} wrap>
{[
"model_info",
"ownr_nm",
@@ -21,7 +20,7 @@ const InformationSettings = ({ t }) => (
"subtotal",
"tasks"
].map((item) => (
<Col span={4} key={item}>
<Col xs={24} sm={12} md={8} lg={6} key={item}>
<Form.Item name={item} valuePropName="checked">
<Checkbox>{t(`production.labels.${item}`)}</Checkbox>
</Form.Item>

View File

@@ -1,9 +1,8 @@
import { Card, Col, Form, Radio, Row } from "antd";
import React from "react";
import PropTypes from "prop-types";
const LayoutSettings = ({ t }) => (
<Card title={t("production.settings.layout")}>
<Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]}>
{[
{
@@ -48,9 +47,9 @@ const LayoutSettings = ({ t }) => (
]
}
].map(({ name, label, options }) => (
<Col span={4} key={name}>
<Col xs={24} sm={16} md={10} lg={8} key={name}>
<Form.Item name={name} label={label}>
<Radio.Group>
<Radio.Group style={{ display: "flex", flexWrap: "nowrap" }}>
{options.map((option) => (
<Radio.Button key={option.value.toString()} value={option.value}>
{option.label}

View File

@@ -1,8 +1,7 @@
import { Card, Checkbox, Form } from "antd";
import PropTypes from "prop-types";
import { DragDropContext, Draggable, Droppable } from "../trello-board/dnd/lib/index.js";
import { statisticsItems } from "./defaultKanbanSettings.js";
import { Card, Checkbox, Form } from "antd";
import React from "react";
import PropTypes from "prop-types";
const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChanges }) => {
const onDragEnd = (result) => {

View File

@@ -1,17 +1,17 @@
import { SettingOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Button, Card, Col, Form, Popover, Row, Tabs } from "antd";
import { isFunction } from "lodash";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_KANBAN_SETTINGS } from "../../../graphql/user.queries.js";
import { defaultKanbanSettings, mergeWithDefaults } from "./defaultKanbanSettings.js";
import LayoutSettings from "./LayoutSettings.jsx";
import InformationSettings from "./InformationSettings.jsx";
import StatisticsSettings from "./StatisticsSettings.jsx";
import FilterSettings from "./FilterSettings.jsx";
import PropTypes from "prop-types";
import { isFunction } from "lodash";
import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx";
import { SettingOutlined } from "@ant-design/icons";
import InformationSettings from "./InformationSettings.jsx";
import LayoutSettings from "./LayoutSettings.jsx";
import StatisticsSettings from "./StatisticsSettings.jsx";
function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bodyshop, data, onSettingsChange }) {
const [form] = Form.useForm();
@@ -87,7 +87,7 @@ function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bod
};
const overlay = (
<Card style={{ minWidth: "80vw" }}>
<Card style={{ maxWidth: "80vw", width: "100%"}}>
<Form form={form} onFinish={handleFinish} layout="vertical" onValuesChange={handleValuesChange}>
<Tabs
defaultActiveKey="1"

View File

@@ -100,26 +100,28 @@ const BoardContainer = ({
const onLaneDrag = useCallback(
async ({ draggableId, type, source, reason, mode, destination, combine }) => {
setIsDragging(false);
setDragTime(source.droppableId);
if (!type || type !== "lane" || !source || !destination || isEqual(source, destination)) return;
setIsProcessing(true);
// Only update drag time if it's a valid drop with a different destination
if (type === "lane" && source && destination && !isEqual(source, destination)) {
setDragTime(source.droppableId);
setIsProcessing(true);
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: destination.droppableId,
cardId: draggableId,
index: destination.index
})
);
dispatch(
actions.moveCardAcrossLanes({
fromLaneId: source.droppableId,
toLaneId: destination.droppableId,
cardId: draggableId,
index: destination.index
})
);
try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) {
console.error("Error in onLaneDrag", err);
} finally {
setIsProcessing(false);
try {
await onDragEnd({ draggableId, type, source, reason, mode, destination, combine });
} catch (err) {
console.error("Error in onLaneDrag", err);
} finally {
setIsProcessing(false);
}
}
},
[dispatch, onDragEnd, setDragTime]

View File

@@ -120,15 +120,14 @@ const Lane = ({
const Component = orientation === "vertical" ? VirtuosoGrid : Virtuoso;
const FinalComponent = collapsed ? "div" : Component;
const commonProps = {
useWindowScroll: true,
data: renderedCards
data: renderedCards,
customScrollParent: laneRef.current
};
const verticalProps = {
...commonProps,
listClassName: "grid-container",
itemClassName: "grid-item",
customScrollParent: laneRef.current,
components: {
List: ListComponent,
Item: ItemComponent
@@ -142,7 +141,6 @@ const Lane = ({
components: { Item: HeightPreservingItem },
overscan: { main: 3, reverse: 3 },
itemContent: (index, item) => renderDraggable(index, item),
scrollerRef: provided.innerRef,
style: {
minWidth: maxCardWidth,
minHeight: maxLaneHeight
@@ -180,13 +178,14 @@ const Lane = ({
override={orientation !== "horizontal" && (collapsed || !renderedCards.length)}
>
<div
{...provided.droppableProps}
ref={provided.innerRef}
ref={laneRef} // Ensure laneRef is set here
style={{ height: "100%", width: "100%" }} // Make it scrollable
className={`react-trello-lane ${collapsed ? "lane-collapsed" : ""}`}
style={{ ...provided.droppableProps.style }}
>
<FinalComponent {...finalComponentProps} />
{shouldRenderPlaceholder && provided.placeholder}
<div {...provided.droppableProps} ref={provided.innerRef} style={{ ...provided.droppableProps.style }}>
<FinalComponent {...finalComponentProps} />
{shouldRenderPlaceholder && provided.placeholder}
</div>
</div>
</HeightMemoryWrapper>
);

View File

@@ -54,6 +54,9 @@ export function ProfileShopsContainer({ bodyshop, currentUser }) {
//Force window refresh.
//Ping the new partner to refresh.
axios.post("http://localhost:1337/refresh");
window.location.reload();
};

View File

@@ -1,6 +1,6 @@
import { Button, Col, Form, Input, Row, Select, Space, Switch, Typography } from "antd";
import axios from "axios";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -8,16 +8,16 @@ import { calculateScheduleLoad } from "../../redux/application/application.actio
import { selectBodyshop } from "../../redux/user/user.selectors";
import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import BlurWrapper from "../feature-wrapper/blur-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import EmailInput from "../form-items-formatted/email-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import ScheduleDayViewContainer from "../schedule-day-view/schedule-day-view.container";
import ScheduleExistingAppointmentsList from "../schedule-existing-appointments-list/schedule-existing-appointments-list.component";
import "./schedule-job-modal.scss";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import BlurWrapper from "../feature-wrapper/blur-wrapper.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import "./schedule-job-modal.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -60,10 +60,12 @@ export function ScheduleJobModalComponent({
const totalHours =
lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs + lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs;
if (values.start && !values.scheduled_completion)
form.setFieldsValue({
scheduled_completion: dayjs(values.start).businessDaysAdd(totalHours / bodyshop.target_touchtime, "day")
});
if (values.start && !values.scheduled_completion) {
const addDays = bodyshop.ss_configuration.nobusinessdays
? dayjs(values.start).add(totalHours / (bodyshop.target_touchtime || 1), "day")
: dayjs(values.start).businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day");
form.setFieldsValue({ scheduled_completion: addDays });
}
}
};

View File

@@ -1,4 +1,3 @@
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Tabs } from "antd";
import React from "react";
@@ -24,6 +23,8 @@ import ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoIntellipay from "./shop-intellipay-config.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import ShopInfoNotificationsAutoadd from "./shop-info.notifications-autoadd.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -41,6 +42,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
names: ["CriticalPartsScanning", "Enhanced_Payroll"],
splitKey: bodyshop.imexshopid
});
const { scenarioNotificationsOn } = useSocket();
const { t } = useTranslation();
const history = useNavigate();
@@ -137,9 +139,21 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
{
key: "intellipay",
label: InstanceRenderManager({ rome: t("bodyshop.labels.romepay"), imex: t("bodyshop.labels.imexpay") }),
label: InstanceRenderManager({
rome: t("bodyshop.labels.romepay"),
imex: t("bodyshop.labels.imexpay")
}),
children: <ShopInfoIntellipay form={form} />
}
},
...(scenarioNotificationsOn
? [
{
key: "notifications_autoadd",
label: t("bodyshop.labels.notifications.followers"),
children: <ShopInfoNotificationsAutoadd form={form} bodyshop={bodyshop} />
}
]
: [])
];
return (
<Card

View File

@@ -906,6 +906,7 @@ export function ShopInfoGeneral({ form, bodyshop }) {
add();
}}
style={{ width: "100%" }}
id="insurancecos-add-button"
>
{t("general.actions.add")}
</Button>

View File

@@ -0,0 +1,57 @@
import { Form, Typography } from "antd";
import { useTranslation } from "react-i18next";
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component.jsx";
const { Text, Paragraph } = Typography;
export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
const { t } = useTranslation();
// Filter employee options to ensure active employees with valid IDs
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.id && typeof e.id === "string") || [];
return (
<div>
<Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph>
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
{employeeOptions.length > 0 ? (
<Form.Item
name="notification_followers"
rules={[
{
type: "array",
message: t("general.validation.array")
},
{
validator: async (_, value) => {
if (!value || value.length === 0) {
return Promise.resolve(); // Allow empty array
}
const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === "");
if (hasInvalid) {
return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers")));
}
return Promise.resolve();
}
}
]}
>
<EmployeeSearchSelectComponent
style={{ minWidth: "100%" }}
mode="multiple"
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>
) : (
<Text type="secondary">{t("bodyshop.fields.no_employees_available")}</Text>
)}
</div>
);
}

View File

@@ -1,16 +1,15 @@
import { DeleteFilled } from "@ant-design/icons";
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { ColorPicker } from "./shop-info.rostatus.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
@@ -78,6 +77,13 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={["ss_configuration", "nobusinessdays"]}
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={["md_lost_sale_reasons"]}
label={t("bodyshop.fields.md_lost_sale_reasons")}

View File

@@ -25,23 +25,6 @@ export function ShopTemplateTestRender({ bodyshop, query, emailEditorRef, style
emailEditorRef.current.exportHtml(async (data) => {
try {
// const inlineHtml = await axios.post("/render/inlinecss", {
// html: data.html,
// url: `${window.location.protocol}://${window.location.host}/`,
// });
// const { data: contextData } = await client.query({
// query: gql(query),
// variables: variables,
//
// });
// const renderResponse = await axios.post("/render", {
// view: inlineHtml.data,
// context: { ...contextData, bodyshop: bodyshop },
// });
// displayTemplateInWindowNoprint(renderResponse.data);
setLoading(false);
} catch (error) {
setLoading(false);

View File

@@ -1,7 +1,6 @@
import { useLazyQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Form, Input, InputNumber, Select, Switch } from "antd";
import React from "react";
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -19,6 +18,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import JobEmployeeAssignmentsContainer from "./../job-employee-assignments/job-employee-assignments.container";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -319,10 +319,15 @@ export function TimeTicketModalComponent({
}
export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) {
const { t } = useTranslation();
if (loading) return <LoadingSkeleton />;
if (!lineTicketData) return null;
if (!jobid) return null;
return (
<div>
<Space direction="vertical" style={{ width: "100%" }}>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}>
<JobEmployeeAssignmentsContainer job={lineTicketData.jobs_by_pk} />
</Card>
<LaborAllocationsTable
jobId={jobid}
joblines={lineTicketData.joblines}
@@ -332,6 +337,6 @@ export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideT
{!hideTimeTickets && (
<TimeTicketList loading={loading} timetickets={jobid ? lineTicketData.timetickets : []} techConsole />
)}
</div>
</Space>
);
}

View File

@@ -2,10 +2,11 @@ import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Form, Modal, Space } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries";
import { INSERT_NEW_TIME_TICKET, UPDATE_TIME_TICKET } from "../../graphql/timetickets.queries";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
@@ -14,7 +15,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import TimeTicketModalComponent from "./time-ticket-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket,
@@ -81,7 +81,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
}
};
const handleMutationSuccess = (response) => {
const handleMutationSuccess = () => {
notification["success"]({
message: t("timetickets.successes.created")
});
@@ -123,7 +123,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
if (timeTicketModal.open) form.resetFields();
}, [timeTicketModal.open, form]);
const handleFieldsChange = (changedFields, allFields) => {
const handleFieldsChange = (changedFields) => {
if (!!changedFields.employeeid && !!EmployeeAutoCompleteData) {
const emps = EmployeeAutoCompleteData.employees.filter((e) => e.id === changedFields.employeeid);
form.setFieldsValue({
@@ -182,6 +182,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
</Space>
}
destroyOnClose
id="time-ticket-modal"
>
<Form
onFinish={handleFinish}

View File

@@ -113,7 +113,7 @@ export function UpdateAlert({ updateAvailable }) {
</Col>
<Col sm={24} md={8} lg={6}>
<Space wrap>
<Button onClick={() => window.open("https://imex-online.noticeable.news/", "_blank")}>
<Button onClick={() => window.open("https://shopmanagement.canny.io/changelog", "_blank")}>
{i18n.t("general.actions.viewreleasenotes")}
</Button>
<Button loading={loading} type="primary" onClick={() => ReloadNewVersion()}>

View File

@@ -141,6 +141,7 @@ export const QUERY_BODYSHOP = gql`
use_paint_scale_data
intellipay_config
md_ro_guard
notification_followers
employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) {
id
name
@@ -271,6 +272,7 @@ export const UPDATE_SHOP = gql`
md_tasks_presets
intellipay_config
md_ro_guard
notification_followers
employee_teams(order_by: { name: asc }, where: { active: { _eq: true } }) {
id
name

View File

@@ -35,6 +35,30 @@ export const GET_LINE_TICKET_BY_PK = gql`
lbr_adjustments
converted
status
employee_body
employee_body_rel {
id
first_name
last_name
}
employee_csr
employee_csr_rel {
id
first_name
last_name
}
employee_prep
employee_prep_rel {
id
first_name
last_name
}
employee_refinish
employee_refinish_rel {
id
first_name
last_name
}
}
joblines(where: { jobid: { _eq: $id }, removed: { _eq: false } }) {
id

View File

@@ -423,6 +423,7 @@ export const GET_JOB_BY_PK = gql`
actual_completion
actual_delivery
actual_in
acv_amount
adjustment_bottom_line
alt_transport
area_of_damage
@@ -511,6 +512,7 @@ export const GET_JOB_BY_PK = gql`
est_ph1
flat_rate_ats
federal_tax_rate
hit_and_run
id
inproduction
ins_addr1
@@ -683,6 +685,8 @@ export const GET_JOB_BY_PK = gql`
scheduled_delivery
scheduled_in
selling_dealer
estimate_approved
estimate_sent_approval
selling_dealer_contact
servicing_dealer
servicing_dealer_contact
@@ -927,6 +931,8 @@ export const QUERY_JOB_CARD_DETAILS = gql`
date_exported
date_repairstarted
date_scheduled
estimate_sent_approval
estimate_approved
date_estimated
employee_body_rel {
id
@@ -1075,6 +1081,8 @@ export const UPDATE_JOB = gql`
date_repairstarted
date_void
date_lost_sale
estimate_sent_approval
estimate_approved
}
}
}
@@ -2429,6 +2437,8 @@ export const QUERY_PARTS_QUEUE_CARD_DETAILS = gql`
plate_st
po_number
production_vars
estimate_sent_approval
estimate_approved
ro_number
scheduled_completion
scheduled_delivery
@@ -2570,6 +2580,20 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
actual_completion
scheduled_delivery
actual_delivery
labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
larhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _eq: "LAR" } }, { removed: { _eq: false } }] }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
}
}
`;

View File

@@ -91,6 +91,7 @@ export const QUERY_NOTIFICATION_SETTINGS = gql`
associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) {
id
notification_settings
notifications_autoadd
}
}
`;
@@ -103,3 +104,12 @@ export const UPDATE_NOTIFICATION_SETTINGS = gql`
}
}
`;
export const UPDATE_NOTIFICATIONS_AUTOADD = gql`
mutation UPDATE_NOTIFICATIONS_AUTOADD($id: uuid!, $autoadd: Boolean!) {
update_associations_by_pk(pk_columns: { id: $id }, _set: { notifications_autoadd: $autoadd }) {
id
notifications_autoadd
}
}
`;

View File

@@ -0,0 +1,42 @@
import axios from "axios";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
});
export function FeedbackPage({ setBreadcrumbs, setSelectedHeader }) {
const { t } = useTranslation();
useEffect(() => {
document.title = t("titles.feature-request", {
app: InstanceRenderManager({
imex: "$t(titles.imexonline)",
rome: "$t(titles.romeonline)"
})
});
setBreadcrumbs([{ link: "/manage/feature-request", label: t("titles.bc.feature-request") }]);
}, [t, setBreadcrumbs, setSelectedHeader]);
useEffect(() => {
async function RenderCanny() {
const ssoToken = await axios.post("/sso/canny");
window.Canny("render", {
boardToken: "bba97b06-70db-0334-dee7-8108d73ef614",
basePath: `/manage/feature-request`, // See step 2
ssoToken: ssoToken.data, // See step 3,
theme: "light" // options: light [default], dark, auto
});
}
RenderCanny();
}, []);
return <div data-canny />;
}
export default connect(null, mapDispatchToProps)(FeedbackPage);

View File

@@ -1,4 +1,5 @@
import { FloatButton, Layout, Spin } from "antd";
import { Button, FloatButton, Layout, Space, Spin } from "antd";
import { AlertOutlined, BulbOutlined } from "@ant-design/icons";
// import preval from "preval.macro";
import React, { lazy, Suspense, useEffect, useState } from "react";
@@ -19,7 +20,6 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com
import PartnerPingComponent from "../../components/partner-ping/partner-ping.component";
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
import { requestForToken } from "../../firebase/firebase.utils";
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
import UpdateAlert from "../../components/update-alert/update-alert.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -56,6 +56,7 @@ const ContractCreatePage = lazy(() => import("../contract-create/contract-create
const ContractDetailPage = lazy(() => import("../contract-detail/contract-detail.page.container"));
const ContractsList = lazy(() => import("../contracts/contracts.page.container"));
const BillsListPage = lazy(() => import("../bills/bills.page.container"));
const FeatureRequestPage = lazy(() => import("../feature-request/feature-request.page.jsx"));
const JobCostingModal = lazy(() => import("../../components/job-costing-modal/job-costing-modal.container"));
const ReportCenterModal = lazy(() => import("../../components/report-center-modal/report-center-modal.container"));
@@ -180,15 +181,12 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
});
}
}, [alerts, displayedAlertIds, notification]);
useEffect(() => {
const widgetId = InstanceRenderManager({
imex: "IABVNO4scRKY11XBQkNr",
rome: "mQdqARMzkZRUVugJ6TdS"
});
window.noticeable.render("widget", widgetId);
requestForToken().catch((error) => {
console.error(`Unable to request for token.`, error);
window.Canny("initChangelog", {
appID: "680bd2c7ee501290377f6686",
position: "top",
align: "left",
theme: "light" // options: light [default], dark, auto
});
}, []);
@@ -480,6 +478,8 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
// element={<ShopTemplates />}
// />
}
<Route path="/feature-request/*" index element={<FeatureRequestPage />} />
<Route
path="/shop/vendors"
element={
@@ -669,7 +669,12 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
margin: "1rem 0rem"
}}
>
<div style={{ display: "flex" }}>
<Link to="/manage/feature-request">
<Button icon={<BulbOutlined />} type="text">
{t("general.labels.feature-request")}
</Button>
</Link>
<Space>
<WssStatusDisplayComponent />
<div onClick={broadcastMessage}>
{`${InstanceRenderManager({
@@ -677,8 +682,10 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
rome: t("titles.romeonline")
})} - ${import.meta.env.VITE_APP_GIT_SHA_DATE}`}
</div>
<div id="noticeable-widget" style={{ marginLeft: "1rem" }} />
</div>
<Button icon={<AlertOutlined />} data-canny-changelog type="text">
{t("general.labels.changelog")}
</Button>
</Space>
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices
</Link>

View File

@@ -1,7 +1,4 @@
import FingerprintJS from "@fingerprintjs/fingerprintjs";
import * as Sentry from "@sentry/browser";
import { notification } from "antd";
import axios from "axios";
import { setUserId, setUserProperties } from "@firebase/analytics";
import {
checkActionCode,
@@ -12,6 +9,9 @@ import {
} from "@firebase/auth";
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore";
import { getToken } from "@firebase/messaging";
import * as Sentry from "@sentry/react";
import { notification } from "antd";
import axios from "axios";
import i18next from "i18next";
import LogRocket from "logrocket";
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
@@ -351,7 +351,14 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
});
payload.features?.allAccess === true
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
: window.$crisp.push(["set", "session:segments", [["basic"]]]);
: (() => {
const featureKeys = Object.keys(payload.features).filter(
(key) =>
payload.features[key] === true ||
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
);
window.$crisp.push(["set", "session:segments", [["basic", ...featureKeys]]]);
})();
} catch (error) {
console.warn("Couldnt find $crisp.", error.message);
}

View File

@@ -335,7 +335,6 @@
"intellipay_config": {
"cash_discount_percentage": "Cash Discount %",
"enable_cash_discount": "Enable Cash Discounting",
"payment_type": "Payment Type Map",
"payment_map": {
"amex": "American Express",
"disc": "Discover",
@@ -344,7 +343,8 @@
"jcb": "JCB",
"mast": "MasterCard",
"visa": "Visa"
}
},
"payment_type": "Payment Type Map"
},
"invoice_federal_tax_rate": "Invoices - Federal Tax Rate",
"invoice_local_tax_rate": "Invoices - Local Tax Rate",
@@ -601,7 +601,8 @@
"templates": "Templates"
},
"ss_configuration": {
"dailyhrslimit": "Daily Incoming Hours Limit"
"dailyhrslimit": "Daily Incoming Hours Limit",
"nobusinessdays": "Include Weekends"
},
"ssbuckets": {
"color": "Job Color",
@@ -647,7 +648,12 @@
"use_paint_scale_data": "Use Paint Scale Data for Job Costing?",
"uselocalmediaserver": "Use Local Media Server?",
"website": "Website",
"zip_post": "Zip/Postal Code"
"zip_post": "Zip/Postal Code",
"notifications": {
"description": "Select employees to automatically follow new jobs and receive notifications for job updates.",
"placeholder": "Search for employees",
"invalid_followers": "Invalid selection. Please select valid employees."
}
},
"labels": {
"2tiername": "Name => RO",
@@ -727,7 +733,10 @@
"ssbuckets": "Job Size Definitions",
"systemsettings": "System Settings",
"task-presets": "Task Presets",
"workingdays": "Working Days"
"workingdays": "Working Days",
"notifications": {
"followers": "Notifications"
}
},
"operations": {
"contains": "Contains",
@@ -1235,6 +1244,7 @@
"areyousure": "Are you sure?",
"barcode": "Barcode",
"cancel": "Are you sure you want to cancel? Your changes will not be saved.",
"changelog": "Change Log",
"clear": "Clear",
"confirmpassword": "Confirm Password",
"created_at": "Created At",
@@ -1244,6 +1254,7 @@
"errors": "Errors",
"excel": "Excel",
"exceptiontitle": "An error has occurred.",
"feature-request": "Have a feature request?",
"friday": "Friday",
"globalsearch": "Global Search",
"help": "Help",
@@ -1322,9 +1333,9 @@
"notfoundtitle": "We couldn't find what you're looking for...",
"partnernotrunning": "{{app}} has detected that the partner is not running. Please ensure it is running to enable full functionality.",
"rbacunauth": "You are not authorized to view this content. Please reach out to your shop manager to change your access level.",
"submit-for-testing": "Submitted Job for testing successfully.",
"unsavedchanges": "You have unsaved changes.",
"unsavedchangespopup": "You have unsaved changes. Are you sure you want to leave?",
"submit-for-testing": "Submitted Job for testing successfully."
"unsavedchangespopup": "You have unsaved changes. Are you sure you want to leave?"
},
"validation": {
"dateRangeExceeded": "The date range has been exceeded.",
@@ -1635,9 +1646,12 @@
"actual_completion": "Actual Completion",
"actual_delivery": "Actual Delivery",
"actual_in": "Actual In",
"acv_amount": "ACV Amount",
"adjustment_bottom_line": "Adjustments",
"adjustmenthours": "Adjustment Hours",
"alt_transport": "Alt. Trans.",
"estimate_sent_approval": "Estimate Sent for Approval",
"estimate_approved": "Estimate Approved",
"area_of_damage_impact": {
"10": "Left Front Side",
"11": "Left Front Corner",
@@ -1760,9 +1774,10 @@
"est_ct_ln": "Estimator Last Name",
"est_ea": "Estimator Email",
"est_ph1": "Estimator Phone #",
"flat_rate_ats": "Flat Rate ATS?",
"federal_tax_payable": "Federal Tax Payable",
"federal_tax_rate": "Federal Tax Rate",
"flat_rate_ats": "Flat Rate ATS?",
"hit_and_run": "Hit and Run",
"ins_addr1": "Insurance Co. Address",
"ins_city": "Insurance Co. City",
"ins_co_id": "Insurance Co. ID",
@@ -1942,6 +1957,8 @@
"scheddates": "Schedule Dates"
},
"labels": {
"sent": "",
"approved": "",
"accountsreceivable": "Accounts Receivable",
"act_price_ppc": "New Part Price",
"actual_completion_inferred": "$t(jobs.fields.actual_completion) inferred using $t(jobs.fields.scheduled_completion).",
@@ -2316,8 +2333,8 @@
"duplicate": "Duplicate this Job",
"duplicatenolines": "Duplicate this Job without Repair Data",
"newcccontract": "Create Courtesy Car Contract",
"void": "Void Job",
"submit-for-testing": "Submit for Testing"
"submit-for-testing": "Submit for Testing",
"void": "Void Job"
},
"jobsdetail": {
"claimdetail": "Claim Details",
@@ -2423,6 +2440,63 @@
"updated": "Note updated successfully."
}
},
"notifications": {
"actions": {
"remove": "Remove"
},
"aria": {
"toggle": "Toggle Watching Job"
},
"channels": {
"app": "App",
"email": "Email",
"fcm": "Push"
},
"labels": {
"auto-add": "Automatically watch Jobs I import",
"auto-add-success": "Auto watcher status successfully changed.",
"auto-add-failure": "Something went wrong updating your auto watcher status.",
"add-watchers": "Add Watchers",
"add-watchers-team": "Add Team Members",
"employee-search": "Search for an Employee",
"mark-all-read": "Mark All Read",
"new-notification-title": "New Notification:",
"no-watchers": "No Watchers",
"notification-center": "Notification Center",
"notification-popup-title": "Changes for Job #{{ro_number}}",
"notification-settings-failure": "Error saving Notification Settings. {{error}}",
"notification-settings-success": "Notification Settings saved successfully.",
"notificationscenarios": "Job Notification Scenarios",
"ro-number": "RO #{{ro_number}}",
"save": "Save Scenarios",
"scenario": "Scenario",
"show-unread-only": "Show Unread Only",
"teams-search": "Search for a Team",
"unwatch": "Unwatch",
"watch": "Watch",
"watching-issue": "Watching"
},
"scenarios": {
"alternate-transport-changed": "Alternate Transport Changed",
"bill-posted": "Bill Posted",
"critical-parts-status-changed": "Critical Parts Status Changed",
"intake-delivery-checklist-completed": "Intake or Delivery Checklist Completed",
"job-added-to-production": "Job Added to Production",
"job-assigned-to-me": "Job Assigned to Me",
"job-status-change": "Job Status Changed",
"new-media-added-reassigned": "New Media Added or Reassigned",
"new-note-added": "New Note Added",
"new-time-ticket-posted": "New Time Ticket Posted",
"part-marked-back-ordered": "Part Marked Back Ordered",
"payment-collected-completed": "Payment Collected / Completed",
"schedule-dates-changed": "Schedule Dates Changed",
"supplement-imported": "Supplement Imported",
"tasks-updated-created": "Tasks Updated / Created"
},
"tooltips": {
"job-watchers": "Job Watchers"
}
},
"owner": {
"labels": {
"noownerinfo": "No owner information."
@@ -3419,6 +3493,7 @@
"dashboard": "Dashboard",
"dms": "DMS Export",
"export-logs": "Export Logs",
"feature-request": "Feature Requet",
"inventory": "Inventory",
"jobs": "Jobs",
"jobs-active": "Active Jobs",
@@ -3463,6 +3538,7 @@
"dashboard": "Dashboard | {{app}}",
"dms": "DMS Export | {{app}}",
"export-logs": "Export Logs | {{app}}",
"feature-request": "Feature Request | {{app}}",
"imexonline": "ImEX Online",
"inventory": "Inventory | {{app}}",
"jobs": "Active Jobs | {{app}}",
@@ -3680,10 +3756,10 @@
"users": {
"errors": {
"signinerror": {
"auth/invalid-email": "A user with this email does not exist.",
"auth/user-disabled": "User account disabled. ",
"auth/user-not-found": "A user with this email does not exist.",
"auth/wrong-password": "The email and password combination you provided is incorrect.",
"auth/invalid-email": "A user with this email does not exist."
"auth/wrong-password": "The email and password combination you provided is incorrect."
}
}
},
@@ -3783,60 +3859,6 @@
"validation": {
"unique_vendor_name": "You must enter a unique vendor name."
}
},
"notifications": {
"labels": {
"notification-center": "Notification Center",
"scenario": "Scenario",
"notificationscenarios": "Job Notification Scenarios",
"save": "Save Scenarios",
"watching-issue": "Watching",
"add-watchers": "Add Watchers",
"employee-search": "Search for an Employee",
"teams-search": "Search for a Team",
"add-watchers-team": "Add Team Members",
"new-notification-title": "New Notification:",
"show-unread-only": "Show Unread Only",
"mark-all-read": "Mark All Read",
"notification-popup-title": "Changes for Job #{{ro_number}}",
"ro-number": "RO #{{ro_number}}",
"no-watchers": "No Watchers",
"notification-settings-success": "Notification Settings saved successfully.",
"notification-settings-failure": "Error saving Notification Settings. {{error}}",
"watch": "Watch",
"unwatch": "Unwatch"
},
"actions": {
"remove": "Remove"
},
"aria": {
"toggle": "Toggle Watching Job"
},
"tooltips": {
"job-watchers": "Job Watchers"
},
"scenarios": {
"job-assigned-to-me": "Job Assigned to Me",
"bill-posted": "Bill Posted",
"critical-parts-status-changed": "Critical Parts Status Changed",
"part-marked-back-ordered": "Part Marked Back Ordered",
"new-note-added": "New Note Added",
"supplement-imported": "Supplement Imported",
"schedule-dates-changed": "Schedule Dates Changed",
"tasks-updated-created": "Tasks Updated / Created",
"new-media-added-reassigned": "New Media Added or Reassigned",
"new-time-ticket-posted": "New Time Ticket Posted",
"intake-delivery-checklist-completed": "Intake or Delivery Checklist Completed",
"job-added-to-production": "Job Added to Production",
"job-status-change": "Job Status Changed",
"payment-collected-completed": "Payment Collected / Completed",
"alternate-transport-changed": "Alternate Transport Changed"
},
"channels": {
"app": "App",
"email": "Email",
"fcm": "Push"
}
}
}
}

View File

@@ -335,7 +335,6 @@
"intellipay_config": {
"cash_discount_percentage": "",
"enable_cash_discount": "",
"payment_type": "",
"payment_map": {
"amex": "American Express",
"disc": "Discover",
@@ -344,7 +343,8 @@
"jcb": "JCB",
"mast": "MasterCard",
"visa": "Visa"
}
},
"payment_type": ""
},
"invoice_federal_tax_rate": "",
"invoice_local_tax_rate": "",
@@ -601,7 +601,8 @@
"templates": ""
},
"ss_configuration": {
"dailyhrslimit": ""
"dailyhrslimit": "",
"nobusinessdays": ""
},
"ssbuckets": {
"color": "",
@@ -647,7 +648,12 @@
"use_paint_scale_data": "",
"uselocalmediaserver": "",
"website": "",
"zip_post": ""
"zip_post": "",
"notifications": {
"description": "",
"placeholder": "",
"invalid_followers": ""
}
},
"labels": {
"2tiername": "",
@@ -727,7 +733,10 @@
"ssbuckets": "",
"systemsettings": "",
"task-presets": "",
"workingdays": ""
"workingdays": "",
"notifications": {
"followers": ""
}
},
"operations": {
"contains": "",
@@ -1235,6 +1244,7 @@
"areyousure": "",
"barcode": "código de barras",
"cancel": "",
"changelog": "",
"clear": "",
"confirmpassword": "",
"created_at": "",
@@ -1244,6 +1254,7 @@
"errors": "",
"excel": "",
"exceptiontitle": "",
"feature-request": "",
"friday": "",
"globalsearch": "",
"help": "",
@@ -1322,9 +1333,9 @@
"notfoundtitle": "",
"partnernotrunning": "",
"rbacunauth": "",
"submit-for-testing": "",
"unsavedchanges": "Usted tiene cambios no guardados.",
"unsavedchangespopup": "",
"submit-for-testing": ""
"unsavedchangespopup": ""
},
"validation": {
"dateRangeExceeded": "",
@@ -1631,10 +1642,13 @@
"voiding": ""
},
"fields": {
"estimate_sent_approval": "",
"estimate_approved": "",
"active_tasks": "",
"actual_completion": "Realización real",
"actual_delivery": "Entrega real",
"actual_in": "Real en",
"acv_amount": "",
"adjustment_bottom_line": "Ajustes",
"adjustmenthours": "",
"alt_transport": "",
@@ -1760,9 +1774,10 @@
"est_ct_ln": "Apellido del tasador",
"est_ea": "Correo electrónico del tasador",
"est_ph1": "Número de teléfono del tasador",
"flat_rate_ats": "",
"federal_tax_payable": "Impuesto federal por pagar",
"federal_tax_rate": "",
"flat_rate_ats": "",
"hit_and_run": "",
"ins_addr1": "Dirección de Insurance Co.",
"ins_city": "Ciudad de seguros",
"ins_co_id": "ID de la compañía de seguros",
@@ -1942,6 +1957,8 @@
"scheddates": ""
},
"labels": {
"sent": "",
"approved": "",
"accountsreceivable": "",
"act_price_ppc": "",
"actual_completion_inferred": "",
@@ -2316,8 +2333,8 @@
"duplicate": "",
"duplicatenolines": "",
"newcccontract": "",
"void": "",
"submit-for-testing": ""
"submit-for-testing": "",
"void": ""
},
"jobsdetail": {
"claimdetail": "Detalles de la reclamación",
@@ -2423,6 +2440,65 @@
"updated": "Nota actualizada con éxito."
}
},
"notifications": {
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
},
"labels": {
"auto-add-on": "",
"auto-add-off": "",
"auto-add-success": "",
"auto-add-failure": "",
"auto-add-description": "",
"add-watchers": "",
"add-watchers-team": "",
"employee-search": "",
"mark-all-read": "",
"new-notification-title": "",
"no-watchers": "",
"notification-center": "",
"notification-popup-title": "",
"notification-settings-failure": "",
"notification-settings-success": "",
"notificationscenarios": "",
"ro-number": "",
"save": "",
"scenario": "",
"show-unread-only": "",
"teams-search": "",
"unwatch": "",
"watch": "",
"watching-issue": ""
},
"scenarios": {
"alternate-transport-changed": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-assigned-to-me": "",
"job-status-change": "",
"new-media-added-reassigned": "",
"new-note-added": "",
"new-time-ticket-posted": "",
"part-marked-back-ordered": "",
"payment-collected-completed": "",
"schedule-dates-changed": "",
"supplement-imported": "",
"tasks-updated-created": ""
},
"tooltips": {
"job-watchers": ""
}
},
"owner": {
"labels": {
"noownerinfo": ""
@@ -3419,6 +3495,7 @@
"dashboard": "",
"dms": "",
"export-logs": "",
"feature-request": "",
"inventory": "",
"jobs": "",
"jobs-active": "",
@@ -3463,6 +3540,7 @@
"dashboard": "",
"dms": "",
"export-logs": "",
"feature-request": "",
"imexonline": "",
"inventory": "",
"jobs": "Todos los trabajos | {{app}}",
@@ -3680,10 +3758,10 @@
"users": {
"errors": {
"signinerror": {
"auth/invalid-email": "",
"auth/user-disabled": "",
"auth/user-not-found": "",
"auth/wrong-password": "",
"auth/invalid-email": ""
"auth/wrong-password": ""
}
}
},
@@ -3783,60 +3861,6 @@
"validation": {
"unique_vendor_name": ""
}
},
"notifications": {
"labels": {
"notification-center": "",
"scenario": "",
"notificationscenarios": "",
"save": "",
"watching-issue": "",
"add-watchers": "",
"employee-search": "",
"teams-search": "",
"add-watchers-team": "",
"new-notification-title": "",
"show-unread-only": "",
"mark-all-read": "",
"notification-popup-title": "",
"ro-number": "",
"no-watchers": "",
"notification-settings-success": "",
"notification-settings-failure": "",
"watch": "",
"unwatch": ""
},
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"tooltips": {
"job-watchers": ""
},
"scenarios": {
"job-assigned-to-me": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"part-marked-back-ordered": "",
"new-note-added": "",
"supplement-imported": "",
"schedule-dates-changed": "",
"tasks-updated-created": "",
"new-media-added-reassigned": "",
"new-time-ticket-posted": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-status-change": "",
"payment-collected-completed": "",
"alternate-transport-changed": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
}
}
}
}

View File

@@ -335,7 +335,6 @@
"intellipay_config": {
"cash_discount_percentage": "",
"enable_cash_discount": "",
"payment_type": "",
"payment_map": {
"amex": "American Express",
"disc": "Discover",
@@ -344,7 +343,8 @@
"jcb": "JCB",
"mast": "MasterCard",
"visa": "Visa"
}
},
"payment_type": ""
},
"invoice_federal_tax_rate": "",
"invoice_local_tax_rate": "",
@@ -601,7 +601,8 @@
"templates": ""
},
"ss_configuration": {
"dailyhrslimit": ""
"dailyhrslimit": "",
"nobusinessdays": ""
},
"ssbuckets": {
"color": "",
@@ -647,7 +648,12 @@
"use_paint_scale_data": "",
"uselocalmediaserver": "",
"website": "",
"zip_post": ""
"zip_post": "",
"notifications": {
"description": "",
"placeholder": "",
"invalid_followers": ""
}
},
"labels": {
"2tiername": "",
@@ -727,7 +733,10 @@
"ssbuckets": "",
"systemsettings": "",
"task-presets": "",
"workingdays": ""
"workingdays": "",
"notifications": {
"followers": ""
}
},
"operations": {
"contains": "",
@@ -1235,6 +1244,7 @@
"areyousure": "",
"barcode": "code à barre",
"cancel": "",
"changelog": "",
"clear": "",
"confirmpassword": "",
"created_at": "",
@@ -1244,6 +1254,7 @@
"errors": "",
"excel": "",
"exceptiontitle": "",
"feature-request": "",
"friday": "",
"globalsearch": "",
"help": "",
@@ -1322,10 +1333,9 @@
"notfoundtitle": "",
"partnernotrunning": "",
"rbacunauth": "",
"submit-for-testing": "",
"unsavedchanges": "Vous avez des changements non enregistrés.",
"unsavedchangespopup": "",
"submit-for-testing": ""
"unsavedchangespopup": ""
},
"validation": {
"dateRangeExceeded": "",
@@ -1632,10 +1642,13 @@
"voiding": ""
},
"fields": {
"estimate_sent_approval": "",
"estimate_approved": "",
"active_tasks": "",
"actual_completion": "Achèvement réel",
"actual_delivery": "Livraison réelle",
"actual_in": "En réel",
"acv_amount": "",
"adjustment_bottom_line": "Ajustements",
"adjustmenthours": "",
"alt_transport": "",
@@ -1761,9 +1774,10 @@
"est_ct_ln": "Nom de l'évaluateur",
"est_ea": "Courriel de l'évaluateur",
"est_ph1": "Numéro de téléphone de l'évaluateur",
"flat_rate_ats": "",
"federal_tax_payable": "Impôt fédéral à payer",
"federal_tax_rate": "",
"flat_rate_ats": "",
"hit_and_run": "",
"ins_addr1": "Adresse Insurance Co.",
"ins_city": "Insurance City",
"ins_co_id": "ID de la compagnie d'assurance",
@@ -1943,6 +1957,8 @@
"scheddates": ""
},
"labels": {
"sent": "",
"approved": "",
"accountsreceivable": "",
"act_price_ppc": "",
"actual_completion_inferred": "",
@@ -2317,8 +2333,8 @@
"duplicate": "",
"duplicatenolines": "",
"newcccontract": "",
"void": "",
"submit-for-testing": ""
"submit-for-testing": "",
"void": ""
},
"jobsdetail": {
"claimdetail": "Détails de la réclamation",
@@ -2424,6 +2440,65 @@
"updated": "Remarque mise à jour avec succès."
}
},
"notifications": {
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
},
"labels": {
"auto-add-on": "",
"auto-add-off": "",
"auto-add-success": "",
"auto-add-failure": "",
"auto-add-description": "",
"add-watchers": "",
"add-watchers-team": "",
"employee-search": "",
"mark-all-read": "",
"new-notification-title": "",
"no-watchers": "",
"notification-center": "",
"notification-popup-title": "",
"notification-settings-failure": "",
"notification-settings-success": "",
"notificationscenarios": "",
"ro-number": "",
"save": "",
"scenario": "",
"show-unread-only": "",
"teams-search": "",
"unwatch": "",
"watch": "",
"watching-issue": ""
},
"scenarios": {
"alternate-transport-changed": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-assigned-to-me": "",
"job-status-change": "",
"new-media-added-reassigned": "",
"new-note-added": "",
"new-time-ticket-posted": "",
"part-marked-back-ordered": "",
"payment-collected-completed": "",
"schedule-dates-changed": "",
"supplement-imported": "",
"tasks-updated-created": ""
},
"tooltips": {
"job-watchers": ""
}
},
"owner": {
"labels": {
"noownerinfo": ""
@@ -3420,6 +3495,7 @@
"dashboard": "",
"dms": "",
"export-logs": "",
"feature-request": "",
"inventory": "",
"jobs": "",
"jobs-active": "",
@@ -3464,6 +3540,7 @@
"dashboard": "",
"dms": "",
"export-logs": "",
"feature-request": "",
"imexonline": "",
"inventory": "",
"jobs": "Tous les emplois | {{app}}",
@@ -3681,10 +3758,10 @@
"users": {
"errors": {
"signinerror": {
"auth/invalid-email": "",
"auth/user-disabled": "",
"auth/user-not-found": "",
"auth/wrong-password": "",
"auth/invalid-email": ""
"auth/wrong-password": ""
}
}
},
@@ -3784,60 +3861,6 @@
"validation": {
"unique_vendor_name": ""
}
},
"notifications": {
"labels": {
"notification-center": "",
"scenario": "",
"notificationscenarios": "",
"save": "",
"watching-issue": "",
"add-watchers": "",
"employee-search": "",
"teams-search": "",
"add-watchers-team": "",
"new-notification-title": "",
"show-unread-only": "",
"mark-all-read": "",
"notification-popup-title": "",
"ro-number": "",
"no-watchers": "",
"notification-settings-success": "",
"notification-settings-failure": "",
"watch": "",
"unwatch": ""
},
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"tooltips": {
"job-watchers": ""
},
"scenarios": {
"job-assigned-to-me": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"part-marked-back-ordered": "",
"new-note-added": "",
"supplement-imported": "",
"schedule-dates-changed": "",
"tasks-updated-created": "",
"new-media-added-reassigned": "",
"new-time-ticket-posted": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-status-change": "",
"payment-collected-completed": "",
"alternate-transport-changed": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
}
}
}
}

View File

@@ -14,10 +14,7 @@ const onServiceWorkerUpdate = (registration) => {
<Button
onClick={async () => {
window.open(
InstanceRenderManager({
imex: "https://imex-online.noticeable.news/",
rome: "https://rome-online.noticeable.news/"
}),
`https://shopmanagement.canny.io/changelog`,
"_blank"
);
}}

View File

@@ -15,7 +15,7 @@ const currentDatePST = new Date()
.reverse()
.join("-");
const sentryRelease =
`${import.meta.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}-${process.env.VITE_GIT_COMMIT_HASH}`.trim();
`${import.meta.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}`.trim();
if (!import.meta.env.DEV) {
Sentry.init({

View File

@@ -31,6 +31,15 @@
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
- name: Podium Data Pump
webhook: '{{HASURA_API_URL}}/data/podium'
schedule: 15 5 * * *
include_in_metadata: true
payload: {}
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
comment: ""
- name: Rome Usage Report
webhook: '{{HASURA_API_URL}}/data/usagereport'
schedule: 0 12 * * 5

View File

@@ -216,6 +216,7 @@
- id
- kanban_settings
- notification_settings
- notifications_autoadd
- qbo_realmId
- shopid
- useremail
@@ -232,6 +233,7 @@
- default_prod_list_view
- kanban_settings
- notification_settings
- notifications_autoadd
- qbo_realmId
filter:
user:
@@ -1002,9 +1004,11 @@
- md_tasks_presets
- md_to_emails
- messagingservicesid
- notification_followers
- pbs_configuration
- pbs_serialnumber
- phone
- podiumid
- prodtargethrs
- production_config
- region_config
@@ -1103,6 +1107,7 @@
- md_ro_statuses
- md_tasks_presets
- md_to_emails
- notification_followers
- pbs_configuration
- phone
- prodtargethrs
@@ -3594,6 +3599,7 @@
- actual_completion
- actual_delivery
- actual_in
- acv_amount
- adj_g_disc
- adj_strdis
- adj_towdis
@@ -3696,9 +3702,12 @@
- est_ph1
- est_st
- est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate
- flat_rate_ats
- g_bett_amt
- hit_and_run
- id
- inproduction
- ins_addr1
@@ -3865,6 +3874,7 @@
- actual_completion
- actual_delivery
- actual_in
- acv_amount
- adj_g_disc
- adj_strdis
- adj_towdis
@@ -3968,9 +3978,12 @@
- est_ph1
- est_st
- est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate
- flat_rate_ats
- g_bett_amt
- hit_and_run
- id
- inproduction
- ins_addr1
@@ -4149,6 +4162,7 @@
- actual_completion
- actual_delivery
- actual_in
- acv_amount
- adj_g_disc
- adj_strdis
- adj_towdis
@@ -4252,9 +4266,12 @@
- est_ph1
- est_st
- est_zip
- estimate_approved
- estimate_sent_approval
- federal_tax_rate
- flat_rate_ats
- g_bett_amt
- hit_and_run
- id
- inproduction
- ins_addr1
@@ -4581,12 +4598,34 @@
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\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\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 \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\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
url: '{{$base_url}}/notifications/events/handleJobsChange'
version: 2
- name: notifications_jobs_autoadd
definition:
enable_manual: false
insert:
columns: '*'
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
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"
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handleAutoAdd'
version: 2
- name: os_jobs
definition:
delete:

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"."bodyshops" add column "podiumid" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "podiumid" 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 "hit_and_run" boolean
-- null default 'false';

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "hit_and_run" boolean
null default 'false';

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 "acv_amount" numeric
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "acv_amount" numeric
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"."bodyshops" add column "notification_followers" json
-- null default json_build_object();

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "notification_followers" json
null default json_build_object();

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"."associations" add column "notifications_autoadd" boolean
-- null default 'false';

View File

@@ -0,0 +1,2 @@
alter table "public"."associations" add column "notifications_autoadd" boolean
null default 'false';

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" alter column "notification_followers" set default json_build_object();

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" alter column "notification_followers" set default json_build_array();

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 "estimate_sent_approval" timestamptz
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "estimate_sent_approval" timestamptz
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 "estimate_approved" timestamptz
-- null;

View File

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

4863
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"version": "0.2.0",
"license": "UNLICENSED",
"engines": {
"node": ">=18.0.0",
"node": ">=22.13.0",
"npm": ">=8.0.0"
},
"scripts": {
@@ -16,60 +16,56 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.782.0",
"@aws-sdk/client-elasticache": "^3.782.0",
"@aws-sdk/client-s3": "^3.782.0",
"@aws-sdk/client-secrets-manager": "^3.782.0",
"@aws-sdk/client-ses": "^3.782.0",
"@aws-sdk/credential-provider-node": "^3.782.0",
"@aws-sdk/lib-storage": "^3.782.0",
"@aws-sdk/s3-request-presigner": "^3.782.0",
"@aws-sdk/client-cloudwatch-logs": "^3.804.0",
"@aws-sdk/client-elasticache": "^3.804.0",
"@aws-sdk/client-s3": "^3.804.0",
"@aws-sdk/client-secrets-manager": "^3.804.0",
"@aws-sdk/client-ses": "^3.804.0",
"@aws-sdk/credential-provider-node": "^3.804.0",
"@aws-sdk/lib-storage": "^3.804.0",
"@aws-sdk/s3-request-presigner": "^3.804.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1",
"aws4": "^1.13.2",
"axios": "^1.8.4",
"bee-queue": "^1.7.1",
"better-queue": "^3.8.12",
"bluebird": "^3.7.2",
"bullmq": "^5.48.0",
"bullmq": "^5.52.2",
"chart.js": "^4.4.8",
"cloudinary": "^2.6.0",
"cloudinary": "^2.6.1",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"cors": "2.8.5",
"crisp-status-reporter": "^1.2.2",
"csrf": "^3.1.0",
"dd-trace": "^5.45.0",
"dd-trace": "^5.51.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"firebase-admin": "^13.2.0",
"graphql": "^16.10.0",
"graphql": "^16.11.0",
"graphql-request": "^6.1.0",
"inline-css": "^4.0.3",
"intuit-oauth": "^4.2.0",
"ioredis": "^5.6.0",
"json-2-csv": "^5.5.9",
"jsonwebtoken": "^9.0.2",
"juice": "^11.0.1",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.48",
"multer": "^1.4.5-lts.1",
"node-mailjet": "^6.0.8",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"phone": "^3.1.58",
"query-string": "^9.1.2",
"recursive-diff": "^1.0.9",
"redis": "^4.7.0",
"rimraf": "^6.0.1",
"skia-canvas": "^2.0.2",
"soap": "^1.1.10",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.5.2",
"twilio": "^5.6.0",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0",
@@ -77,16 +73,14 @@
"xmlbuilder2": "^3.1.1"
},
"devDependencies": {
"@eslint/js": "^9.24.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"eslint": "^9.24.0",
"@eslint/js": "^9.26.0",
"eslint": "^9.26.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.5.3",
"source-map-explorer": "^2.5.2",
"supertest": "^7.1.0",
"vitest": "^3.1.1"
"vitest": "^3.1.3"
}
}

View File

@@ -4,6 +4,7 @@ require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
// Commented out due to stability issues
if (process.env.NODE_ENV) {
require("dd-trace").init({
profiling: true,
@@ -33,7 +34,6 @@ const {
DescribeReplicationGroupsCommand
} = require("@aws-sdk/client-elasticache");
const { InstanceRegion } = require("./server/utils/instanceMgr");
const StartStatusReporter = require("./server/utils/statusReporter");
const { registerCleanupTask, initializeCleanupManager } = require("./server/utils/cleanupManager");
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
@@ -117,6 +117,8 @@ const applyRoutes = ({ app }) => {
app.use("/cdk", require("./server/routes/cdkRoutes"));
app.use("/csi", require("./server/routes/csiRoutes"));
app.use("/payroll", require("./server/routes/payrollRoutes"));
app.use("/sso", require("./server/routes/ssoRoutes"));
app.use("/integrations", require("./server/routes/intergrationRoutes"));
// Default route for forbidden access
app.get("/", (req, res) => {
@@ -390,13 +392,6 @@ const main = async () => {
applyRoutes({ app });
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
const StatusReporter = StartStatusReporter();
registerCleanupTask(async () => {
if (isFunction(StatusReporter?.end)) {
StatusReporter.end();
}
});
try {
await server.listen(port);
logger.log(`Server started on port ${port}`, "INFO", "api");

View File

@@ -217,7 +217,7 @@ exports.PbsExportAp = async function (socket, { billids, txEnvelope }) {
socket.emit("ap-export-success", billid);
} else {
CdkBase.createLogEvent(socket, "ERROR", `Export was not succesful.`);
CdkBase.createLogEvent(socket, "ERROR", `Export was not successful.`);
socket.emit("ap-export-failure", {
billid,
error: AccountPostingChange.Message

View File

@@ -105,14 +105,14 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte
socket.emit("export-success", socket.JobData.id);
} else {
CdkBase.createLogEvent(socket, "ERROR", `Export was not succesful.`);
CdkBase.createLogEvent(socket, "ERROR", `Export was not successful.`);
}
} catch (error) {
CdkBase.createLogEvent(socket, "ERROR", `Error encountered in CdkSelectedCustomer. ${error}`);
await InsertFailedExportLog(socket, error);
}
};
// Was Successful
async function CheckForErrors(socket, response) {
if (response.WasSuccessful === undefined || response.WasSuccessful === true) {
CdkBase.createLogEvent(socket, "DEBUG", `Successful response from DMS. ${response.Message || ""}`);

View File

@@ -68,7 +68,7 @@ exports.default = async (req, res) => {
return;
}
if (process.env.NODE_ENV === "PRODUCTION") {
if (process.env.NODE_ENV === "production") {
res.sendStatus(200);
return;
}

View File

@@ -2,7 +2,6 @@ const path = require("path");
const queries = require("../graphql-client/queries");
const moment = require("moment-timezone");
const converter = require("json-2-csv");
const _ = require("lodash");
const logger = require("../utils/logger");
const fs = require("fs");
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");

View File

@@ -3,4 +3,5 @@ exports.autohouse = require("./autohouse").default;
exports.chatter = require("./chatter").default;
exports.claimscorp = require("./claimscorp").default;
exports.kaizen = require("./kaizen").default;
exports.usageReport = require("./usageReport").default;
exports.usageReport = require("./usageReport").default;
exports.podium = require("./podium").default;

211
server/data/podium.js Normal file
View File

@@ -0,0 +1,211 @@
const path = require("path");
const queries = require("../graphql-client/queries");
const moment = require("moment-timezone");
const converter = require("json-2-csv");
const logger = require("../utils/logger");
const fs = require("fs");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
let Client = require("ssh2-sftp-client");
const client = require("../graphql-client/graphql-client").client;
const { sendServerEmail } = require("../email/sendemail");
const ftpSetup = {
host: process.env.PODIUM_HOST,
port: process.env.PODIUM_PORT,
username: process.env.PODIUM_USER,
password: process.env.PODIUM_PASSWORD,
debug:
process.env.NODE_ENV !== "production"
? (message, ...data) => logger.log(message, "DEBUG", "api", null, data)
: () => {},
algorithms: {
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
}
};
exports.default = async (req, res) => {
// Only process if in production environment.
if (process.env.NODE_ENV !== "production") {
res.sendStatus(403);
return;
}
// Only process if the appropriate token is provided.
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) {
res.sendStatus(401);
return;
}
// Send immediate response and continue processing.
res.status(202).json({
success: true,
message: "Processing request ...",
timestamp: new Date().toISOString()
});
try {
logger.log("podium-start", "DEBUG", "api", null, null);
const allCSVResults = [];
const allErrors = [];
const { bodyshops } = await client.request(queries.GET_PODIUM_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("podium-shopsToProcess-generated", "DEBUG", "api", null, null);
if (shopsToProcess.length === 0) {
logger.log("podium-shopsToProcess-empty", "DEBUG", "api", null, null);
return;
}
await processShopData(shopsToProcess, start, end, skipUpload, allCSVResults, allErrors);
await sendServerEmail({
subject: `Podium Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allCSVResults.map((x) => ({
imexshopid: x.imexshopid,
filename: x.filename,
count: x.count,
result: x.result
})),
null,
2
)}`
});
logger.log("podium-end", "DEBUG", "api", null, null);
} catch (error) {
logger.log("podium-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
};
async function processShopData(shopsToProcess, start, end, skipUpload, allCSVResults, allErrors) {
for (const bodyshop of shopsToProcess) {
const erroredJobs = [];
try {
logger.log("podium-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
const { jobs, bodyshops_by_pk } = await client.request(queries.PODIUM_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(2, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
const podiumObject = jobs.map((j) => {
return {
"Podium Account ID": bodyshops_by_pk.podiumid,
"First Name": j.ownr_co_nm ? null : j.ownr_fn,
"Last Name": j.ownr_co_nm ? j.ownr_co_nm : j.ownr_ln,
"SMS Number": null,
"Phone 1": j.ownr_ph1,
"Phone 2": j.ownr_ph2,
Email: j.ownr_ea,
"Delivered Date":
(j.actual_delivery && moment(j.actual_delivery).tz(bodyshop.timezone).format("MM/DD/YYYY")) || ""
};
});
if (erroredJobs.length > 0) {
logger.log("podium-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number))
});
}
const csvObj = {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
csv: converter.json2csv(podiumObject, { emptyFieldValue: "" }),
filename: `${bodyshop.podiumid}-${moment().format("YYYYMMDDTHHMMss")}.csv`,
count: podiumObject.length
};
if (skipUpload) {
fs.writeFileSync(`./logs/${csvObj.filename}`, csvObj.csv);
} else {
await uploadViaSFTP(csvObj);
}
allCSVResults.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
podiumid: bodyshop.podiumid,
count: csvObj.count,
filename: csvObj.filename,
result: csvObj.result
});
logger.log("podium-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
//Error at the shop level.
logger.log("podium-error-shop", "ERROR", "api", bodyshop.id, { error: error.message, stack: error.stack });
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
podiumid: bodyshop.podiumid,
fatal: true,
errors: [error.toString()]
});
} finally {
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
podiumid: bodyshop.podiumid,
errors: erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
}))
});
}
}
}
async function uploadViaSFTP(csvObj) {
const sftp = new Client();
sftp.on("error", (errors) =>
logger.log("podium-sftp-connection-error", "ERROR", "api", csvObj.bodyshopid, {
error: errors.message,
stack: errors.stack
})
);
try {
//Connect to the FTP and upload all.
await sftp.connect(ftpSetup);
try {
csvObj.result = await sftp.put(Buffer.from(csvObj.xml), `${csvObj.filename}`);
logger.log("podium-sftp-upload", "DEBUG", "api", csvObj.bodyshopid, {
imexshopid: csvObj.imexshopid,
filename: csvObj.filename,
result: csvObj.result
});
} catch (error) {
logger.log("podium-sftp-upload-error", "ERROR", "api", csvObj.bodyshopid, {
filename: csvObj.filename,
error: error.message,
stack: error.stack
});
throw error;
}
} catch (error) {
logger.log("podium-sftp-error", "ERROR", "api", csvObj.bodyshopid, {
error: error.message,
stack: error.stack
});
throw error;
} finally {
sftp.end();
}
}

View File

@@ -1,5 +1,3 @@
const moment = require("moment");
const { default: RenderInstanceManager } = require("../utils/instanceMgr");
const { header, end, start } = require("./html");
// Required Strings
@@ -7,19 +5,6 @@ const { header, end, start } = require("./html");
// - subHeader - The subheader of the email
// - body - The body of the email
// Optional Strings (Have default values)
// - footer - The footer of the email
// - dateLine - The date line of the email
const defaultFooter = () => {
return RenderInstanceManager({
imex: "ImEX Online Collision Repair Management System",
rome: "Rome Technologies"
});
};
const now = () => moment().format("MM/DD/YYYY @ hh:mm a");
/**
* Generate the email template
* @param strings
@@ -32,81 +17,48 @@ const generateEmailTemplate = (strings) => {
header +
start +
`
<table class="row">
<tbody>
<tr>
<th class="small-12 large-12 columns first last">
<table>
<tbody>
<tr>
<td>
<h6 style="text-align:left"><strong>${strings.header}</strong></h6>
</td>
</tr>
<tr>
<td>
<p style="font-size:90%">${strings.subHeader}</p>
</td>
</tr>
</tbody>
</table>
</th>
</tr>
</tbody>
</table>
<!-- Report Title -->
${
strings.header &&
`
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<h6 style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; color: inherit; word-wrap: normal; font-weight: normal; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 23px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; text-align: center;"><strong style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">${strings.header}</strong></h6>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
`
}
${
strings.subHeader &&
`
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 95%;">${strings.subHeader}</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
`
}
<!-- End Report Title -->
<!-- Task Detail -->
<table class="row">
<tbody>
<tr>
<th class="small-12 large-12 columns first last">
<table>
<tbody>
<tr>
<td>${strings.body}</td>
</tr>
</tbody>
</table>
</th>
</tr>
</tbody>
</table>
<!-- End Task Detail -->
<!-- Footer -->
<table class="row collapsed footer" id="non-printable">
<tbody>
<tr>
<th class="small-3 large-3 columns first">
<table>
<tbody>
<tr>
<td><p style="font-size:70%; padding-right:10px">${strings?.dateLine || now()}</p></td>
</tr>
</tbody>
</table>
</th>
<th class="small-6 large-6 columns">
<table>
<tbody>
<tr>
<td><p style="font-size:70%; text-align:center">${strings?.footer || defaultFooter()}</p></td>
</tr>
</tbody>
</table>
</th>
<th class="small-3 large-3 columns last">
<table>
<tbody>
<tr>
<td><p style="font-size:70%">&nbsp;</p></td>
</tr>
</tbody>
</table>
</th>
</tr>
</tbody>
</table>` +
end
${
strings.body &&
`
<!-- Report Detail -->
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
${strings.body}
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<!-- End Report Detail -->
`
}
` +
end(strings.dateLine)
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,9 @@ const logEmail = async (req, email) => {
to: req?.body?.to,
cc: req?.body?.cc,
subject: req?.body?.subject,
email
email,
errorMessage: error?.message,
errorStack: error?.stack
// info,
});
}
@@ -68,6 +70,7 @@ const sendServerEmail = async ({ subject, text }) => {
]
}
},
// eslint-disable-next-line no-unused-vars
(err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, {
message: err?.message,
@@ -80,6 +83,108 @@ const sendServerEmail = async ({ subject, text }) => {
}
};
const sendWelcomeEmail = async ({ to, resetLink, dateLine, features, bcc }) => {
try {
await mailer.sendMail({
from: InstanceManager({
imex: `ImEX Online <noreply@imex.online>`,
rome: `Rome Online <noreply@romeonline.io>`
}),
to,
bcc,
subject: InstanceManager({
imex: "Welcome to the ImEX Online platform.",
rome: "Welcome to the Rome Online platform."
}),
html: generateEmailTemplate({
header: InstanceManager({
imex: "Welcome to the ImEX Online platform.",
rome: "Welcome to the Rome Online platform."
}),
subHeader: `Your ${InstanceManager({imex: features?.allAccess ? "ImEX Online": "ImEX Lite", rome: features?.allAccess ? "RO Manager" : "RO Basic"})} shop setup has been completed, and this email will include all the information you need to begin.`,
body: `
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To finish setting up your account, visit this link and enter your desired password. <a href=${resetLink} style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Reset Password</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To access your ${InstanceManager({imex: features.allAccess ? "ImEX Online": "ImEX Lite", rome: features.allAccess ? "RO Manager" : "RO Basic"})} shop, visit <a href=${InstanceManager({imex: "https://imex.online/", rome: "https://romeonline.io/"})} style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${InstanceManager({imex: "imex.online", rome: "romeonline.io"})}</a>. Your username is your email, and your password is what you previously set up. Contact support for additional logins.</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
${InstanceManager({
rome: `
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To push estimates over from your estimating system, you must download the Web-Est EMS Unzipper & Rome Online Partner (Computers using Windows only). Here are some steps to help you get started.</p>
</td><tr>
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Download and set up the Web-Est EMS Unzipper - <a href="https://help.imex.online/en/article/how-to-set-up-the-ems-unzip-downloader-on-web-est-n9hbcv/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">How to setup the EMS Unzip Downloader on Web-Est</a></li>
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Download and set up Rome Online Partner - <a href="https://help.imex.online/en/article/setting-up-the-rome-online-partner-1xsw8tb/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Setting up the Rome Online Partner</a></li>
</ul>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Once you successfully set up the partner, now it's time to do some initial in-product items: Please note, <b>an estimate must be exported from the estimating platform to use tours.</b></p>
</td><tr>
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Send estimate from Web-Est to RO Basic - <a href="https://help.imex.online/en/article/how-to-send-estimates-from-web-est-to-the-management-system-ox0h9a/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">How to setup the EMS Unzip Downloader on Web-Est</a></li>
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Once completed, learn how to use RO Basic by accessing the tours at the bottom middle of the screen (labeled “Training Tours”). These walkthroughs will show you how to navigate from creating an RO to closing an RO - <a href="https://www.youtube.com/watch?v=gcbSe5med0I" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">ROME Collision Management Youtube Training Videos</a></li>
</ul>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">If you need any assistance with setting up the programs, or if you want a dedicated Q&A session with one of our customer success specialists, schedule by clicking this link - <a href="https://rometech.zohobookings.com/#/PSAT" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Rome Basic Training Booking</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">If you have additional questions or need any support, feel free to use the RO Basic support chat (blue chat box located in the bottom right corner) or give us a call at <a href="tel:14103576700" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">(410) 357-6700</a>. We are here to help make your experience seamless!</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
`
})}
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">In addition to the training tour, you can also book a live one-on-one demo to see exactly how our system can help streamline the repair process at your shop, schedule by clicking this link - <a href="https://outlook.office.com/bookwithme/user/0aa3ae2c6d59497d9f93fb72479848dc@imexsystems.ca/meetingtype/Qy7CsXl5MkuUJ0NRD7B1AA2?anonymous&ep=mlink" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${InstanceManager({imex: "ImEX Lite", rome: "Rome Basic"})} Demo Booking</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 0px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Thanks,</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 0px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">The ${InstanceManager({imex: "ImEX Online", rome: "Rome Online"})} Team</p>
`,
dateLine
})
});
} catch (error) {
logger.log("server-email-failure", "error", null, null, { error });
}
};
const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => {
try {
mailer.sendMail(
@@ -93,6 +198,7 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
...(type === "text" ? { text } : { html }),
attachments: attachments || null
},
// eslint-disable-next-line no-unused-vars
(err, info) => {
// (message, type, user, record, meta
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
@@ -143,22 +249,20 @@ const sendEmail = async (req, res) => {
to: req.body.to,
cc: req.body.cc,
subject: req.body.subject,
attachments:
[
...((req.body.attachments &&
req.body.attachments.map((a) => {
return {
filename: a.filename,
path: a.path
};
})) ||
[]),
...downloadedMedia.map((a) => {
attachments: [
...(req.body.attachments &&
req.body.attachments.map((a) => {
return {
path: a
filename: a.filename,
path: a.path
};
})
] || null,
})),
...downloadedMedia.map((a) => {
return {
path: a
};
})
],
html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html,
ses: {
// optional extra arguments for SendRawEmail
@@ -273,6 +377,7 @@ ${body.bounce?.bouncedRecipients.map(
)}
`
},
// eslint-disable-next-line no-unused-vars
(err, info) => {
logger.log("sns-error", err ? "error" : "debug", "api", null, {
errorMessage: err?.message,
@@ -294,5 +399,6 @@ module.exports = {
sendEmail,
sendServerEmail,
sendTaskEmail,
emailBounce
emailBounce,
sendWelcomeEmail
};

View File

@@ -17,11 +17,13 @@ const { formatTaskPriority } = require("../notifications/stringHelpers");
const tasksEmailQueue = taskEmailQueue();
// Cleanup function for the Tasks Email Queue
// eslint-disable-next-line no-unused-vars
const tasksEmailQueueCleanup = async () => {
try {
// Example async operation
// console.log("Performing Tasks Email Reminder process cleanup...");
await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve()));
// eslint-disable-next-line no-unused-vars
} catch (err) {
// console.error("Tasks Email Reminder process cleanup failed:", err);
}
@@ -254,10 +256,15 @@ const tasksRemindEmail = async (req, res) => {
header: `${allTasks.length} Tasks require your attention`,
subHeader: `Please click on the Tasks below to view the Task.`,
dateLine,
body: `<ul>
body: `
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; margin: 1%; padding-left: 30px;">
${allTasks
.map((task) =>
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
`
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">
<a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a>
</li>
`.trim()
)
.join("")}
</ul>`

View File

@@ -1,14 +1,10 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const admin = require("firebase-admin");
const logger = require("../utils/logger");
//const { sendProManagerWelcomeEmail } = require("../email/sendemail");
const client = require("../graphql-client/graphql-client").client;
const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON);
//const generateEmailTemplate = require("../email/generateTemplate");
const admin = require("firebase-admin");
const moment = require("moment-timezone");
const logger = require("../utils/logger");
const client = require("../graphql-client/graphql-client").client;
const { sendWelcomeEmail } = require("../email/sendemail");
const { GET_USER_BY_EMAIL } = require("../graphql-client/queries");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
@@ -201,6 +197,94 @@ const unsubscribe = async (req, res) => {
}
};
const getWelcomeEmail = async (req, res) => {
const { authid, email, bcc } = req.body;
try {
// Fetch user from Firebase
const userRecord = await admin.auth().getUser(authid);
if (!userRecord) {
throw { status: 404, message: "User not found in Firebase." };
}
// Fetch user data from the database using GraphQL
const dbUserResult = await client.request(GET_USER_BY_EMAIL, { email: email.toLowerCase() });
const dbUser = dbUserResult?.users?.[0];
if (!dbUser) {
throw { status: 404, message: "User not found in database." };
}
// Validate email before proceeding
if (!dbUser.validemail) {
logger.log("admin-send-welcome-email-skip", "debug", req.user.email, null, {
message: "User email is not valid, skipping email.",
email
});
return res.status(200).json({ message: "User email is not valid, email not sent." });
}
// Generate password reset link
const resetLink = await admin.auth().generatePasswordResetLink(dbUser.email);
// Send welcome email
await sendWelcomeEmail({
to: dbUser.email,
resetLink,
dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"),
features: dbUser.associations?.[0]?.bodyshop?.features,
bcc
});
// Log success and return response
logger.log("admin-send-welcome-email", "debug", req.user.email, null, {
request: req.body,
ioadmin: true,
emailSentTo: email
});
return res.status(200).json({ message: "Welcome email sent successfully." });
} catch (error) {
logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error });
if (!res.headersSent) {
return res.status(error.status || 500).json({
message: error.message || "Error sending welcome email.",
error
});
}
}
};
const getResetLink = async (req, res) => {
const { authid, email } = req.body;
logger.log("admin-reset-link", "debug", req.user.email, null, { authid, email });
try {
// Fetch user from Firebase
const userRecord = await admin.auth().getUser(authid);
if (!userRecord) {
throw { status: 404, message: "User not found in Firebase." };
}
// Generate password reset link
const resetLink = await admin.auth().generatePasswordResetLink(email);
// Log success and return response
logger.log("admin-reset-link-success", "debug", req.user.email, null, {
request: req.body,
ioadmin: true
});
return res.status(200).json({ message: "Reset link generated successfully.", resetLink });
} catch (error) {
return res.status(error.status || 500).json({
message: error.message || "Error generating reset link.",
error
});
}
};
module.exports = {
admin,
createUser,
@@ -208,23 +292,7 @@ module.exports = {
getUser,
sendNotification,
subscribe,
unsubscribe
unsubscribe,
getWelcomeEmail,
getResetLink
};
//Admin claims code.
// const uid = "JEqqYlsadwPEXIiyRBR55fflfko1";
// admin
// .auth()
// .getUser(uid)
// .then((user) => {
// console.log(user);
// admin.auth().setCustomUserClaims(uid, {
// ioadmin: true,
// "https://hasura.io/jwt/claims": {
// "x-hasura-default-role": "debug",
// "x-hasura-allowed-roles": ["admin"],
// "x-hasura-user-id": uid,
// },
// });
// });

View File

@@ -1,17 +1,19 @@
const GraphQLClient = require("graphql-request").GraphQLClient;
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
//New bug introduced with Graphql Request.
// https://github.com/prisma-labs/graphql-request/issues/206
// const { Headers } = require("cross-fetch");
// global.Headers = global.Headers || Headers;
exports.client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
headers: {
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET
}
});
exports.unauthclient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT);
const unauthorizedClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT);
module.exports = {
client,
unauthorizedClient
};

View File

@@ -9,6 +9,7 @@ query FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID($mssid: String!, $phone: String!) {
}
}`;
// Unused
exports.GET_JOB_BY_RO_NUMBER = `
query GET_JOB_BY_RO_NUMBER($ro_number: String!) {
jobs(where:{ro_number:{_eq:$ro_number}}) {
@@ -1323,6 +1324,27 @@ exports.KAIZEN_QUERY = `query KAIZEN_EXPORT($start: timestamptz, $bodyshopid: uu
}
}`;
exports.PODIUM_QUERY = `query PODIUM_EXPORT($start: timestamptz, $bodyshopid: uuid!, $end: timestamptz) {
bodyshops_by_pk(id: $bodyshopid){
id
shopname
podiumid
timezone
}
jobs(where: {_and: [{converted: {_eq: true}}, {actual_delivery: {_gt: $start}}, {actual_delivery: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}, {_or: [{ownr_ph1: {_is_null: false}}, {ownr_ea: {_is_null: false}}]}]}) {
actual_delivery
id
created_at
ro_number
ownr_fn
ownr_ln
ownr_co_nm
ownr_ph1
ownr_ph2
ownr_ea
}
}`;
exports.UPDATE_JOB = `
mutation UPDATE_JOB($jobId: uuid!, $job: jobs_set_input!) {
update_jobs(where: { id: { _eq: $jobId } }, _set: $job) {
@@ -1752,6 +1774,7 @@ exports.QUERY_JOB_COSTING_DETAILS_MULTI = ` query QUERY_JOB_COSTING_DETAILS_MULT
}
}`;
// Exists in Commented out Query
exports.INSERT_IOEVENT = ` mutation INSERT_IOEVENT($event: ioevents_insert_input!) {
insert_ioevents_one(object: $event) {
id
@@ -1848,6 +1871,16 @@ exports.GET_KAIZEN_SHOPS = `query GET_KAIZEN_SHOPS($imexshopid: [String]) {
}
}`;
exports.GET_PODIUM_SHOPS = `query GET_PODIUM_SHOPS {
bodyshops(where: {podiumid: {_is_null: false}, _or: {podiumid: {_neq: ""}}}){
id
shopname
podiumid
imexshopid
timezone
}
}`;
exports.DELETE_ALL_DMS_VEHICLES = `mutation DELETE_ALL_DMS_VEHICLES{
delete_dms_vehicles(where: {}) {
affected_rows
@@ -2771,6 +2804,7 @@ exports.GET_BODYSHOP_BY_ID = `
imexshopid
intellipay_config
state
notification_followers
}
}
`;
@@ -2853,3 +2887,84 @@ query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) {
email
}
}`;
exports.GET_USER_BY_EMAIL = `
query GET_USER_BY_EMAIL($email: String!) {
users(where: {email: {_eq: $email}}) {
email
validemail
associations {
id
shopid
bodyshop {
id
convenient_company
features
timezone
}
}
}
}`;
// Define the GraphQL query to get a job by RO number and shop ID
exports.GET_JOB_BY_RO_NUMBER_AND_SHOP_ID = `
query GET_JOB_BY_RO_NUMBER_AND_SHOP_ID($roNumber: String!, $shopId: uuid!) {
jobs(where: {ro_number: {_eq: $roNumber}, shopid: {_eq: $shopId}}, limit: 1) {
id
shopid
bodyshop {
timezone
}
}
}
`;
// Define the mutation to insert a new document
exports.INSERT_NEW_DOCUMENT = `
mutation INSERT_NEW_DOCUMENT($docInput: [documents_insert_input!]!) {
insert_documents(objects: $docInput) {
returning {
id
name
key
}
}
}
`;
exports.INSERT_JOB_WATCHERS = `
mutation INSERT_JOB_WATCHERS($watchers: [job_watchers_insert_input!]!) {
insert_job_watchers(objects: $watchers, on_conflict: { constraint: job_watchers_pkey, update_columns: [] }) {
affected_rows
}
}
`;
exports.GET_NOTIFICATION_WATCHERS = `
query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!) {
associations(where: {
_and: [
{ shopid: { _eq: $shopId } },
{ active: { _eq: true } },
{ notifications_autoadd: { _eq: true } }
]
}) {
id
useremail
}
employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) {
user_email
}
}
`;
exports.GET_JOB_WATCHERS_MINIMAL = `
query GET_JOB_WATCHERS_MINIMAL($jobid: uuid!) {
job_watchers(where: { jobid: { _eq: $jobid } }) {
user_email
user {
authid
}
}
}
`;

View File

@@ -0,0 +1,143 @@
// Notes: At the moment we take in RO Number, and ShopID. This is not very good considering the RO number can often be null, need
// to ask if it is possible that we just send the Job ID itself, this way we don't need to really care about the bodyshop, and we
// don't risk getting a null
const axios = require("axios");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, INSERT_NEW_DOCUMENT } = require("../../graphql-client/queries");
const { InstanceRegion } = require("../../utils/instanceMgr");
const moment = require("moment/moment");
const client = require("../../graphql-client/graphql-client").client;
const S3_BUCKET = process.env?.IMGPROXY_DESTINATION_BUCKET;
/**
* @description VSSTA integration route
* @type {string[]}
*/
const requiredParams = [
"shop_id",
"ro_nbr",
"pdf_download_link",
"company_api_key",
"scan_type",
"scan_time",
"technician",
"year",
"make",
"model"
];
const vsstaIntegrationRoute = async (req, res) => {
const { logger } = req;
if (!S3_BUCKET) {
logger.log("vssta-integration-missing-bucket", "error", "api", "vssta");
return res.status(500).json({ error: "Improper configuration" });
}
try {
const missingParams = requiredParams.filter((param) => !req.body[param]);
if (missingParams.length > 0) {
logger.log(`vssta-integration-missing-param`, "error", "api", "vssta", {
params: missingParams
});
return res.status(400).json({
error: "Missing required parameters",
missingParams
});
}
// technician, year, make, model, is also available.
const { shop_id, ro_nbr, pdf_download_link, scan_type, scan_time, company_api_key } = req.body;
// 1. Get the job record by ro_number and shop_id
const jobResult = await client.request(GET_JOB_BY_RO_NUMBER_AND_SHOP_ID, {
roNumber: ro_nbr,
shopId: shop_id
});
if (!jobResult.jobs || jobResult.jobs.length === 0) {
logger.log(`vssta-integration-missing-ro`, "error", "api", "vssta");
return res.status(404).json({ error: "Job not found" });
}
const job = jobResult.jobs[0];
// 2. Download the base64-encoded PDF string from the provided link
const pdfResponse = await axios.get(pdf_download_link, {
responseType: "text", // Expect base64 string
headers: {
"auth-token": company_api_key
}
});
// 3. Decode the base64 string to a PDF buffer
const base64String = pdfResponse.data.replace(/^data:application\/pdf;base64,/, "");
const pdfBuffer = Buffer.from(base64String, "base64");
// 4. Generate key for S3
const timestamp = moment(scan_time).tz(job.bodyshop.timezone).format("YYYYMMDD-HHmmss");
const fileName = `${timestamp}_VSSTA_${scan_type}`;
const s3Key = `${job.shopid}/${job.id}/${fileName.replace(/[^A-Z0-9]+/gi, "_")}.pdf`;
// 5. Generate presigned URL for S3 upload
const s3Client = new S3Client({ region: InstanceRegion() });
const putCommand = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: s3Key,
ContentType: "application/pdf",
StorageClass: "INTELLIGENT_TIERING"
});
const presignedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: 360 });
// 6. Upload the decoded PDF to S3
await axios.put(presignedUrl, pdfBuffer, {
headers: { "Content-Type": "application/pdf" }
});
// 7. Create document record in database
const documentMeta = {
jobid: job.id,
uploaded_by: "VSSTA Integration",
name: fileName,
key: s3Key,
type: "application/pdf",
extension: "pdf",
bodyshopid: job.shopid,
size: pdfBuffer.length,
takenat: scan_time
};
const documentInsert = await client.request(INSERT_NEW_DOCUMENT, {
docInput: [documentMeta]
});
if (!documentInsert.insert_documents?.returning?.length) {
logger.log(`vssta-integration-failed-to-create-document-record`, "error", "api", "vssta", {
params: missingParams
});
return res.status(500).json({ error: "Failed to create document record" });
}
return res.status(200).json({
message: "VSSTA integration successful",
documentId: documentInsert.insert_documents.returning[0].id
});
} catch (error) {
logger.log(`vssta-integration-general`, "error", "api", "vssta", {
error: error?.message,
stack: error?.stack
});
return res.status(500).json({ error: error.message });
}
};
module.exports = vsstaIntegrationRoute;

View File

@@ -107,18 +107,25 @@ const handleInvoiceBasedPayment = async (values, logger, logMeta, res) => {
});
// Create payment response record
const responseResults = await gqlClient.request(INSERT_PAYMENT_RESPONSE, {
paymentResponse: {
amount: values.total,
bodyshopid: bodyshop.id,
paymentid: paymentResult.id,
jobid: job.id,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
});
const responseResults = await gqlClient
.request(INSERT_PAYMENT_RESPONSE, {
paymentResponse: {
amount: values.total,
bodyshopid: bodyshop.id,
paymentid: paymentResult.insert_payments.returning[0].id,
jobid: job.id,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
})
.catch((err) => {
logger.log("intellipay-postback-invoice-response-error", "ERROR", "api", null, {
err,
...logMeta
});
});
logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, {
responseResults,

View File

@@ -1,5 +1,6 @@
const { sendTaskEmail } = require("../../email/sendemail");
const generateEmailTemplate = require("../../email/generateTemplate");
const { InstanceEndpoints } = require("../../utils/instanceMgr");
/**
* @description Send notification email to the user
@@ -22,11 +23,9 @@ const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, lo
body: jobs.jobs
.map(
(job) =>
`Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${
job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()
} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
`<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}</p>`
)
.join("<br/>")
.join("")
})
});
} catch (error) {

View File

@@ -37,7 +37,9 @@ beforeEach(() => {
]
})
.mockResolvedValueOnce({
id: "payment123"
insert_payments: {
returning: [{ id: "payment123" }]
}
})
.mockResolvedValueOnce({
insert_payment_response: {

View File

@@ -1,8 +1,12 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const logger = require("../utils/logger");
const { Upload } = require("@aws-sdk/lib-storage");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { InstanceRegion } = require("../utils/instanceMgr");
const archiver = require("archiver");
const stream = require("node:stream");
const base64UrlEncode = require("./util/base64UrlEncode");
const createHmacSha256 = require("./util/createHmacSha256");
const {
S3Client,
PutObjectCommand,
@@ -10,35 +14,36 @@ const {
CopyObjectCommand,
DeleteObjectCommand
} = require("@aws-sdk/client-s3");
const { Upload } = require("@aws-sdk/lib-storage");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const crypto = require("crypto");
const { InstanceRegion } = require("../utils/instanceMgr");
const {
GET_DOCUMENTS_BY_JOB,
QUERY_TEMPORARY_DOCS,
GET_DOCUMENTS_BY_IDS,
DELETE_MEDIA_DOCUMENTS
} = require("../graphql-client/queries");
const archiver = require("archiver");
const stream = require("node:stream");
const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` //Direct Lambda function access to bypass CDN.
const imgproxyKey = process.env.IMGPROXY_KEY;
const imgproxySalt = process.env.IMGPROXY_SALT;
const imgproxyDestinationBucket = process.env.IMGPROXY_DESTINATION_BUCKET;
//Generate a signed upload link for the S3 bucket.
//All uploads must be going to the same shop and jobid.
exports.generateSignedUploadUrls = async (req, res) => {
/**
* Generate a Signed URL Link for the s3 bucket.
* All Uploads must be going to the same Shop and JobId
* @param req
* @param res
* @returns {Promise<*>}
*/
const generateSignedUploadUrls = async (req, res) => {
const { filenames, bodyshopid, jobid } = req.body;
try {
logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, { filenames, bodyshopid, jobid });
logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, {
filenames,
bodyshopid,
jobid
});
const signedUrls = [];
for (const filename of filenames) {
const key = filename;
const key = filename;
const client = new S3Client({ region: InstanceRegion() });
const command = new PutObjectCommand({
Bucket: imgproxyDestinationBucket,
@@ -50,24 +55,32 @@ exports.generateSignedUploadUrls = async (req, res) => {
}
logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls });
res.json({
return res.json({
success: true,
signedUrls
});
} catch (error) {
res.status(400).json({
success: false,
logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, {
message: error.message,
stack: error.stack
});
logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, {
return res.status(400).json({
success: false,
message: error.message,
stack: error.stack
});
}
};
exports.getThumbnailUrls = async (req, res) => {
/**
* Get Thumbnail URLS
* @param req
* @param res
* @returns {Promise<*>}
*/
const getThumbnailUrls = async (req, res) => {
const { jobid, billid } = req.body;
try {
@@ -86,10 +99,11 @@ exports.getThumbnailUrls = async (req, res) => {
for (const document of data.documents) {
//Format to follow:
//<Cloudfront_to_lambda>/<hmac with SHA of entire request URI path (with base64 encoded URL if needed), beginning with unencoded/unhashed Salt>/<remainder of url - resize params >/< base 64 URL encoded to image path>
//<Cloudfront_to_lambda>/<hmac with SHA of entire request URI path (with base64 encoded URL if needed), beginning with un-encoded/un-hashed Salt>/<remainder of url - resize params >/< base 64 URL encoded to image path>
//When working with documents from Cloudinary, the URL does not include the extension.
let key;
if (/\.[^/.]+$/.test(document.key)) {
key = document.key;
} else {
@@ -98,12 +112,12 @@ exports.getThumbnailUrls = async (req, res) => {
// Build the S3 path to the object.
const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`;
const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path);
//Thumbnail Generation Block
const thumbProxyPath = `${thumbResizeParams}/${base64UrlEncodedKeyString}`;
const thumbHmacSalt = createHmacSha256(`${imgproxySalt}/${thumbProxyPath}`);
//Full Size URL block
const fullSizeProxyPath = `${base64UrlEncodedKeyString}`;
const fullSizeHmacSalt = createHmacSha256(`${imgproxySalt}/${fullSizeProxyPath}`);
@@ -114,8 +128,8 @@ exports.getThumbnailUrls = async (req, res) => {
Bucket: imgproxyDestinationBucket,
Key: key
});
const presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 });
s3Props.presignedGetUrl = presignedGetUrl;
s3Props.presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 });
const originalProxyPath = `raw:1/${base64UrlEncodedKeyString}`;
const originalHmacSalt = createHmacSha256(`${imgproxySalt}/${originalProxyPath}`);
@@ -133,7 +147,7 @@ exports.getThumbnailUrls = async (req, res) => {
});
}
res.json(proxiedUrls);
return res.json(proxiedUrls);
//Iterate over them, build the link based on the media type, and return the array.
} catch (error) {
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, {
@@ -142,57 +156,72 @@ exports.getThumbnailUrls = async (req, res) => {
message: error.message,
stack: error.stack
});
res.status(400).json({ message: error.message, stack: error.stack });
return res.status(400).json({ message: error.message, stack: error.stack });
}
};
exports.getBillFiles = async (req, res) => {
//Givena bill ID, get the documents associated to it.
};
exports.downloadFiles = async (req, res) => {
/**
* Download Files
* @param req
* @param res
* @returns {Promise<*>}
*/
const downloadFiles = async (req, res) => {
//Given a series of document IDs or keys, generate a file (or a link) to download all images in bulk
const { jobid, billid, documentids } = req.body;
const { jobId, billid, documentids } = req.body;
try {
logger.log("imgproxy-download", "DEBUG", req.user?.email, jobid, { billid, jobid, documentids });
logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids });
//Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components.
const client = req.userGraphQLClient;
//Query for the keys of the document IDs
const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids });
//Using the Keys, get all of the S3 links, zip them, and send back to the client.
//Using the Keys, get all the S3 links, zip them, and send back to the client.
const s3client = new S3Client({ region: InstanceRegion() });
const archiveStream = archiver("zip");
archiveStream.on("error", (error) => {
console.error("Archival encountered an error:", error);
throw new Error(error);
});
const passthrough = new stream.PassThrough();
archiveStream.pipe(passthrough);
const passThrough = new stream.PassThrough();
archiveStream.pipe(passThrough);
for (const key of data.documents.map((d) => d.key)) {
const response = await s3client.send(new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key }));
// :: `response.Body` is a Buffer
console.log(path.basename(key));
const response = await s3client.send(
new GetObjectCommand({
Bucket: imgproxyDestinationBucket,
Key: key
})
);
archiveStream.append(response.Body, { name: path.basename(key) });
}
archiveStream.finalize();
await archiveStream.finalize();
const archiveKey = `archives/${jobid}/archive-${new Date().toISOString()}.zip`;
const archiveKey = `archives/${jobId || "na"}/archive-${new Date().toISOString()}.zip`;
const parallelUploads3 = new Upload({
client: s3client,
queueSize: 4, // optional concurrency configuration
leavePartsOnError: false, // optional manually handle dropped parts
params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passthrough }
params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passThrough }
});
parallelUploads3.on("httpUploadProgress", (progress) => {
console.log(progress);
});
// Disabled progress logging for upload, uncomment if needed
// parallelUploads3.on("httpUploadProgress", (progress) => {
// console.log(progress);
// });
await parallelUploads3.done();
const uploadResult = await parallelUploads3.done();
//Generate the presigned URL to download it.
const presignedUrl = await getSignedUrl(
s3client,
@@ -200,20 +229,27 @@ exports.downloadFiles = async (req, res) => {
{ expiresIn: 360 }
);
res.json({ success: true, url: presignedUrl });
return res.json({ success: true, url: presignedUrl });
//Iterate over them, build the link based on the media type, and return the array.
} catch (error) {
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, {
jobid,
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobId, {
jobId,
billid,
message: error.message,
stack: error.stack
});
res.status(400).json({ message: error.message, stack: error.stack });
return res.status(400).json({ message: error.message, stack: error.stack });
}
};
exports.deleteFiles = async (req, res) => {
/**
* Delete Files
* @param req
* @param res
* @returns {Promise<*>}
*/
const deleteFiles = async (req, res) => {
//Mark a file for deletion in s3. Lifecycle deletion will actually delete the copy in the future.
//Mark as deleted from the documents section of the database.
const { ids } = req.body;
@@ -232,7 +268,7 @@ exports.deleteFiles = async (req, res) => {
(async () => {
try {
// Delete the original object
const deleteResult = await s3client.send(
await s3client.send(
new DeleteObjectCommand({
Bucket: imgproxyDestinationBucket,
Key: document.key
@@ -250,23 +286,30 @@ exports.deleteFiles = async (req, res) => {
const result = await Promise.all(deleteTransactions);
const errors = result.filter((d) => d.error);
//Delete only the succesful deletes.
//Delete only the successful deletes.
const deleteMutationResult = await client.request(DELETE_MEDIA_DOCUMENTS, {
ids: result.filter((t) => !t.error).map((d) => d.id)
});
res.json({ errors, deleteMutationResult });
return res.json({ errors, deleteMutationResult });
} catch (error) {
logger.log("imgproxy-delete-files-error", "ERROR", req.user.email, null, {
ids,
message: error.message,
stack: error.stack
});
res.status(400).json({ message: error.message, stack: error.stack });
return res.status(400).json({ message: error.message, stack: error.stack });
}
};
exports.moveFiles = async (req, res) => {
/**
* Move Files
* @param req
* @param res
* @returns {Promise<*>}
*/
const moveFiles = async (req, res) => {
const { documents, tojobid } = req.body;
try {
logger.log("imgproxy-move-files", "DEBUG", req.user.email, null, { documents, tojobid });
@@ -278,7 +321,7 @@ exports.moveFiles = async (req, res) => {
(async () => {
try {
// Copy the object to the new key
const copyresult = await s3client.send(
await s3client.send(
new CopyObjectCommand({
Bucket: imgproxyDestinationBucket,
CopySource: `${imgproxyDestinationBucket}/${document.from}`,
@@ -288,7 +331,7 @@ exports.moveFiles = async (req, res) => {
);
// Delete the original object
const deleteResult = await s3client.send(
await s3client.send(
new DeleteObjectCommand({
Bucket: imgproxyDestinationBucket,
Key: document.from
@@ -297,7 +340,12 @@ exports.moveFiles = async (req, res) => {
return document;
} catch (error) {
return { id: document.id, from: document.from, error: error, bucket: imgproxyDestinationBucket };
return {
id: document.id,
from: document.from,
error: error,
bucket: imgproxyDestinationBucket
};
}
})()
);
@@ -307,6 +355,7 @@ exports.moveFiles = async (req, res) => {
const errors = result.filter((d) => d.error);
let mutations = "";
result
.filter((d) => !d.error)
.forEach((d, idx) => {
@@ -321,14 +370,16 @@ exports.moveFiles = async (req, res) => {
});
const client = req.userGraphQLClient;
if (mutations !== "") {
const mutationResult = await client.request(`mutation {
${mutations}
}`);
res.json({ errors, mutationResult });
} else {
res.json({ errors: "No images were succesfully moved on remote server. " });
return res.json({ errors, mutationResult });
}
return res.json({ errors: "No images were successfully moved on remote server. " });
} catch (error) {
logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, {
documents,
@@ -336,13 +387,15 @@ exports.moveFiles = async (req, res) => {
message: error.message,
stack: error.stack
});
res.status(400).json({ message: error.message, stack: error.stack });
return res.status(400).json({ message: error.message, stack: error.stack });
}
};
function base64UrlEncode(str) {
return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function createHmacSha256(data) {
return crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url");
}
module.exports = {
generateSignedUploadUrls,
getThumbnailUrls,
downloadFiles,
deleteFiles,
moveFiles
};

View File

@@ -1,42 +1,55 @@
const path = require("path");
const _ = require("lodash");
const logger = require("../utils/logger");
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const determineFileType = require("./util/determineFileType");
const { DELETE_MEDIA_DOCUMENTS } = require("../graphql-client/queries");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
var cloudinary = require("cloudinary").v2;
const cloudinary = require("cloudinary").v2;
cloudinary.config(process.env.CLOUDINARY_URL);
/**
* @description Creates a signed upload URL for Cloudinary.
* @param req
* @param res
*/
const createSignedUploadURL = (req, res) => {
logger.log("media-signed-upload", "DEBUG", req.user.email, null, null);
res.send(cloudinary.utils.api_sign_request(req.body, process.env.CLOUDINARY_API_SECRET));
};
exports.createSignedUploadURL = createSignedUploadURL;
/**
* @description Downloads files from Cloudinary.
* @param req
* @param res
*/
const downloadFiles = (req, res) => {
const { ids } = req.body;
logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null);
const url = cloudinary.utils.download_zip_url({
public_ids: ids,
flatten_folders: true
});
res.send(url);
};
exports.downloadFiles = downloadFiles;
/**
* @description Deletes files from Cloudinary and Apollo.
* @param req
* @param res
* @returns {Promise<void>}
*/
const deleteFiles = async (req, res) => {
const { ids } = req.body;
const types = _.groupBy(ids, (x) => DetermineFileType(x.type));
const types = _.groupBy(ids, (x) => determineFileType(x.type));
logger.log("media-bulk-delete", "DEBUG", req.user.email, ids, null);
const returns = [];
if (types.image) {
//delete images
@@ -47,8 +60,8 @@ const deleteFiles = async (req, res) => {
)
);
}
if (types.video) {
//delete images returns.push(
returns.push(
await cloudinary.api.delete_resources(
types.video.map((x) => x.key),
@@ -56,8 +69,8 @@ const deleteFiles = async (req, res) => {
)
);
}
if (types.raw) {
//delete images returns.push(
returns.push(
await cloudinary.api.delete_resources(
types.raw.map((x) => `${x.key}.${x.extension}`),
@@ -68,6 +81,7 @@ const deleteFiles = async (req, res) => {
// Delete it on apollo.
const successfulDeletes = [];
returns.forEach((resType) => {
Object.keys(resType.deleted).forEach((key) => {
if (resType.deleted[key] === "deleted" || resType.deleted[key] === "not_found") {
@@ -77,7 +91,7 @@ const deleteFiles = async (req, res) => {
});
try {
const result = await client.request(queries.DELETE_MEDIA_DOCUMENTS, {
const result = await client.request(DELETE_MEDIA_DOCUMENTS, {
ids: ids.filter((i) => successfulDeletes.includes(i.key)).map((i) => i.id)
});
@@ -91,24 +105,29 @@ const deleteFiles = async (req, res) => {
}
};
exports.deleteFiles = deleteFiles;
/**
* @description Renames keys in Cloudinary and updates the database.
* @param req
* @param res
* @returns {Promise<void>}
*/
const renameKeys = async (req, res) => {
const { documents, tojobid } = req.body;
logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents);
const proms = [];
documents.forEach((d) => {
proms.push(
(async () => {
try {
const res = {
return {
id: d.id,
...(await cloudinary.uploader.rename(d.from, d.to, {
resource_type: DetermineFileType(d.type)
resource_type: determineFileType(d.type)
}))
};
return res;
} catch (error) {
return { id: d.id, from: d.from, error: error };
}
@@ -148,18 +167,13 @@ const renameKeys = async (req, res) => {
}`);
res.json({ errors, mutationResult });
} else {
res.json({ errors: "No images were succesfully moved on remote server. " });
res.json({ errors: "No images were successfully moved on remote server. " });
}
};
exports.renameKeys = renameKeys;
//Also needs to be updated in upload utility and mobile app.
function DetermineFileType(filetype) {
if (!filetype) return "auto";
else if (filetype.startsWith("image")) return "image";
else if (filetype.startsWith("video")) return "video";
else if (filetype.startsWith("application/pdf")) return "image";
else if (filetype.startsWith("application")) return "raw";
return "auto";
}
module.exports = {
createSignedUploadURL,
downloadFiles,
deleteFiles,
renameKeys
};

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import determineFileType from "../util/determineFileType";
import base64UrlEncode from "../util/base64UrlEncode";
describe("Media Utils", () => {
describe("base64UrlEncode", () => {
it("should encode string to base64url format", () => {
expect(base64UrlEncode("hello world")).toBe("aGVsbG8gd29ybGQ");
});
it('should replace "+" with "-"', () => {
// '+' in base64 appears when encoding specific binary data
expect(base64UrlEncode("hello+world")).toBe("aGVsbG8rd29ybGQ");
});
it('should replace "/" with "_"', () => {
expect(base64UrlEncode("path/to/resource")).toBe("cGF0aC90by9yZXNvdXJjZQ");
});
it('should remove trailing "=" characters', () => {
// Using a string that will produce padding in base64
expect(base64UrlEncode("padding==")).toBe("cGFkZGluZz09");
});
});
describe("createHmacSha256", () => {
let createHmacSha256;
const originalEnv = process.env;
beforeEach(async () => {
vi.resetModules();
process.env = { ...originalEnv };
process.env.IMGPROXY_KEY = "test-key";
// Dynamically import the module after setting env var
const module = await import("../util/createHmacSha256");
createHmacSha256 = module.default;
});
afterEach(() => {
process.env = originalEnv;
});
it("should create a valid HMAC SHA-256 hash", () => {
const result = createHmacSha256("test-data");
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("should produce consistent hashes for the same input", () => {
const hash1 = createHmacSha256("test-data");
const hash2 = createHmacSha256("test-data");
expect(hash1).toBe(hash2);
});
it("should produce different hashes for different inputs", () => {
const hash1 = createHmacSha256("test-data-1");
const hash2 = createHmacSha256("test-data-2");
expect(hash1).not.toBe(hash2);
});
});
describe("determineFileType", () => {
it('should return "auto" when no filetype is provided', () => {
expect(determineFileType()).toBe("auto");
expect(determineFileType(null)).toBe("auto");
expect(determineFileType(undefined)).toBe("auto");
});
it('should return "image" for image filetypes', () => {
expect(determineFileType("image/jpeg")).toBe("image");
expect(determineFileType("image/png")).toBe("image");
expect(determineFileType("image/gif")).toBe("image");
});
it('should return "video" for video filetypes', () => {
expect(determineFileType("video/mp4")).toBe("video");
expect(determineFileType("video/quicktime")).toBe("video");
expect(determineFileType("video/x-msvideo")).toBe("video");
});
it('should return "image" for PDF files', () => {
expect(determineFileType("application/pdf")).toBe("image");
});
it('should return "raw" for other application types', () => {
expect(determineFileType("application/zip")).toBe("raw");
expect(determineFileType("application/json")).toBe("raw");
expect(determineFileType("application/msword")).toBe("raw");
});
it('should return "auto" for unrecognized types', () => {
expect(determineFileType("audio/mpeg")).toBe("auto");
expect(determineFileType("text/html")).toBe("auto");
expect(determineFileType("unknown-type")).toBe("auto");
});
});
});

View File

@@ -0,0 +1,9 @@
/**
* @description Converts a string to a base64url encoded string.
* @param str
* @returns {string}
*/
const base64UrlEncode = (str) =>
Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
module.exports = base64UrlEncode;

View File

@@ -0,0 +1,12 @@
const crypto = require("crypto");
const imgproxyKey = process.env.IMGPROXY_KEY;
/**
* @description Creates a HMAC SHA-256 hash of the given data.
* @param data
* @returns {string}
*/
const createHmacSha256 = (data) => crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url");
module.exports = createHmacSha256;

View File

@@ -0,0 +1,17 @@
/**
* @description Determines the file type based on the filetype string.
* @note Also needs to be updated in the mobile app utility.
* @param filetype
* @returns {string}
*/
const determineFileType = (filetype) => {
if (!filetype) return "auto";
else if (filetype.startsWith("image")) return "image";
else if (filetype.startsWith("video")) return "video";
else if (filetype.startsWith("application/pdf")) return "image";
else if (filetype.startsWith("application")) return "raw";
return "auto";
};
module.exports = determineFileType;

View File

@@ -0,0 +1,17 @@
/**
* VSSTA Integration Middleware
* @param req
* @param res
* @param next
* @returns {*}
*/
const vsstaIntegrationMiddleware = (req, res, next) => {
if (req?.headers?.["vssta-integration-secret"] !== process.env?.VSSTA_INTEGRATION_SECRET) {
return res.status(401).send("Unauthorized");
}
req.isIntegrationAuthorized = true;
next();
};
module.exports = vsstaIntegrationMiddleware;

View File

@@ -0,0 +1,127 @@
/**
* @module autoAddWatchers
* @description
* 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.
*/
const { client: gqlClient } = require("../graphql-client/graphql-client");
const { isEmpty } = require("lodash");
const {
GET_JOB_WATCHERS_MINIMAL,
GET_NOTIFICATION_WATCHERS,
INSERT_JOB_WATCHERS
} = require("../graphql-client/queries");
// If true, the user who commits the action will NOT receive notifications; if false, they will.
const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "false";
/**
* Adds watchers to a new job based on notifications_autoadd and notification_followers.
*
* @param {Object} req - The request object containing event data and logger.
* @returns {Promise<void>} Resolves when watchers are added or if no action is needed.
* @throws {Error} If critical data (e.g., jobId, shopId) is missing.
*/
const autoAddWatchers = async (req) => {
const { event, trigger } = req.body;
const {
logger,
sessionUtils: { getBodyshopFromRedis }
} = req;
// Validate that this is an INSERT event, bail
if (trigger?.name !== "notifications_jobs_autoadd" || event.op !== "INSERT" || event.data.old) {
return;
}
const jobId = event?.data?.new?.id;
const shopId = event?.data?.new?.shopid;
const roNumber = event?.data?.new?.ro_number || "unknown";
if (!jobId || !shopId) {
throw new Error(`Missing jobId (${jobId}) or shopId (${shopId}) for auto-add watchers`);
}
const hasuraUserRole = event?.session_variables?.["x-hasura-role"];
const hasuraUserId = event?.session_variables?.["x-hasura-user-id"];
try {
// Fetch bodyshop data from Redis
const bodyshopData = await getBodyshopFromRedis(shopId);
const notificationFollowers = bodyshopData?.notification_followers || [];
// Execute queries in parallel
const [notificationData, existingWatchersData] = await Promise.all([
gqlClient.request(GET_NOTIFICATION_WATCHERS, {
shopId,
employeeIds: notificationFollowers.filter((id) => id)
}),
gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId })
]);
// Get users with notifications_autoadd: true
const autoAddUsers =
notificationData?.associations?.map((assoc) => ({
email: assoc.useremail,
associationId: assoc.id
})) || [];
// Get users from notification_followers
const followerEmails =
notificationData?.employees
?.filter((e) => e.user_email)
?.map((e) => ({
email: e.user_email,
associationId: null
})) || [];
// 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)) {
acc.push(user);
}
return acc;
}, []);
if (isEmpty(usersToAdd)) {
return;
}
// Check existing watchers to avoid duplicates
const existingWatcherEmails = existingWatchersData?.job_watchers?.map((w) => w.user_email) || [];
// Filter out already existing watchers and optionally the user who created the job
const newWatchers = usersToAdd
.filter((user) => !existingWatcherEmails.includes(user.email))
.filter((user) => {
if (FILTER_SELF_FROM_WATCHERS && hasuraUserRole === "user") {
const userData = existingWatchersData?.job_watchers?.find((w) => w.user?.authid === hasuraUserId);
return userData ? user.email !== userData.user_email : true;
}
return true;
})
.map((user) => ({
jobid: jobId,
user_email: user.email
}));
if (isEmpty(newWatchers)) {
return;
}
// Insert new watchers
await gqlClient.request(INSERT_JOB_WATCHERS, { watchers: newWatchers });
} catch (error) {
logger.log("Error adding auto-add watchers", "error", "notifications", null, {
message: error?.message,
stack: error?.stack,
jobId,
roNumber
});
throw error; // Re-throw to ensure the error is logged in the handler
}
};
module.exports = { autoAddWatchers };

View File

@@ -6,6 +6,7 @@
*/
const scenarioParser = require("./scenarioParser");
const { autoAddWatchers } = require("./autoAddWatchers"); // New module
/**
* Processes a notification event by invoking the scenario parser.
@@ -185,6 +186,27 @@ const handlePartsDispatchChange = (req, res) => res.status(200).json({ message:
*/
const handlePartsOrderChange = (req, res) => res.status(200).json({ message: "Parts Order change handled." });
/**
* Handle auto-add watchers for new jobs.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleAutoAddWatchers = async (req, res) => {
const { logger } = req;
// Call autoAddWatchers but don't await it; log any error that occurs.
autoAddWatchers(req).catch((error) => {
logger.log("auto-add-watchers-error", "error", "notifications", null, {
message: error?.message,
stack: error?.stack
});
});
return res.status(200).json({ message: "Auto-Add Watchers Event Handled." });
};
module.exports = {
handleJobsChange,
handleBillsChange,
@@ -195,5 +217,6 @@ module.exports = {
handlePartsOrderChange,
handlePaymentsChange,
handleTasksChange,
handleTimeTicketsChange
handleTimeTicketsChange,
handleAutoAddWatchers
};

View File

@@ -133,11 +133,19 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
subHeader: `Dear ${firstName},`,
dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"),
body: `
<p>There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p><br/>
<ul>
${messages.map((msg) => `<li>${msg}</li>`).join("")}
</ul><br/><br/>
<p><a href="${InstanceEndpoints()}/manage/jobs/${jobId}">Please check the job for more details.</a></p>
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 100%;">There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p>
</td></tr></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
${messages.map((msg) => `<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">${msg}</li>`).join("")}
</ul>
</td></tr></table></th>
</tr><tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;"><a href="${InstanceEndpoints()}/manage/jobs/${jobId}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Please check the job for more details.</a></p>
`
});
await sendTaskEmail({
@@ -226,6 +234,7 @@ const getQueue = () => {
* @param {Object} options.logger - Logger instance for logging dispatch events.
* @returns {Promise<void>} Resolves when all notifications are added to the queue.
*/
// eslint-disable-next-line no-unused-vars
const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => {
const emailAddQueue = getQueue();

View File

@@ -182,7 +182,7 @@ const newMediaAddedReassignedBuilder = (data) => {
: data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new
? "moved to this job"
: "updated";
const body = `An ${mediaType} has been ${action}.`;
const body = `A ${mediaType} has been ${action}.`;
return buildNotification(data, "notifications.job.newMediaAdded", body, {
mediaType,

View File

@@ -63,7 +63,9 @@ const scenarioParser = async (req, jobIdField) => {
}
if (!jobId) {
logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications");
if (process?.env?.NODE_ENV === "development") {
logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications");
}
return;
}
@@ -88,7 +90,9 @@ const scenarioParser = async (req, jobIdField) => {
// Exit early if no job watchers are found for this job
if (isEmpty(jobWatchers)) {
logger.log(`No watchers found for jobId "${jobId}", skipping notification parsing`, "info", "notifications");
if (process?.env?.NODE_ENV === "development") {
logger.log(`No watchers found for jobId "${jobId}", skipping notification parsing`, "info", "notifications");
}
return;
}
@@ -130,11 +134,13 @@ const scenarioParser = async (req, jobIdField) => {
// Exit early if no matching scenarios are identified
if (isEmpty(matchingScenarios)) {
logger.log(
`No matching scenarios found for jobId "${jobId}", skipping notification dispatch`,
"info",
"notifications"
);
if (process?.env?.NODE_ENV === "development") {
logger.log(
`No matching scenarios found for jobId "${jobId}", skipping notification dispatch`,
"info",
"notifications"
);
}
return;
}
@@ -157,11 +163,13 @@ const scenarioParser = async (req, jobIdField) => {
// Exit early if no notification associations are found
if (isEmpty(associationsData?.associations)) {
logger.log(
`No notification associations found for jobId "${jobId}", skipping notification dispatch`,
"info",
"notifications"
);
if (process?.env?.NODE_ENV === "development") {
logger.log(
`No notification associations found for jobId "${jobId}", skipping notification dispatch`,
"info",
"notifications"
);
}
return;
}
@@ -196,11 +204,13 @@ const scenarioParser = async (req, jobIdField) => {
// Exit early if no scenarios have eligible watchers after filtering
if (isEmpty(finalScenarioData?.matchingScenarios)) {
logger.log(
`No eligible watchers after filtering for jobId "${jobId}", skipping notification dispatch`,
"info",
"notifications"
);
if (process?.env?.NODE_ENV === "development") {
logger.log(
`No eligible watchers after filtering for jobId "${jobId}", skipping notification dispatch`,
"info",
"notifications"
);
}
return;
}
@@ -259,7 +269,9 @@ const scenarioParser = async (req, jobIdField) => {
}
if (isEmpty(scenariosToDispatch)) {
logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications");
if (process?.env?.NODE_ENV === "development") {
logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications");
}
return;
}

View File

@@ -1,11 +1,10 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const GraphQLClient = require("graphql-request").GraphQLClient;
const _ = require("lodash");
const rdiff = require("recursive-diff");
const logger = require("../utils/logger");
const { json } = require("body-parser");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";

View File

@@ -1,16 +1,11 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const logger = require("../utils/logger");
//const inlineCssTool = require("inline-css");
const juice = require("juice");
exports.inlinecss = async (req, res) => {
//Perform request validation
exports.inlineCSS = async (req, res) => {
const { logger } = req;
const { html } = req.body;
logger.log("email-inline-css", "DEBUG", req.user.email, null, null);
const { html, url } = req.body;
try {
const inlinedHtml = juice(html, {
applyAttributesTableElements: false,
@@ -24,15 +19,4 @@ exports.inlinecss = async (req, res) => {
});
res.send(error.message);
}
// inlineCssTool(html, { url: url })
// .then((inlinedHtml) => {
// res.send(inlinedHtml);
// })
// .catch((error) => {
// logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
// error
// });
// });
};

View File

@@ -2,7 +2,7 @@ const express = require("express");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops");
const { updateUser, getUser, createUser } = require("../firebase/firebase-handler");
const { updateUser, getUser, createUser, getWelcomeEmail, getResetLink } = require("../firebase/firebase-handler");
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
router.use(validateFirebaseIdTokenMiddleware);
@@ -15,5 +15,7 @@ router.post("/updatecounter", updateCounter);
router.post("/updateuser", updateUser);
router.post("/getuser", getUser);
router.post("/createuser", createUser);
router.post("/sendwelcome", getWelcomeEmail);
router.post("/resetlink", getResetLink);
module.exports = router;

View File

@@ -1,11 +1,12 @@
const express = require("express");
const router = express.Router();
const { autohouse, claimscorp, chatter, kaizen, usageReport } = require("../data/data");
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium } = require("../data/data");
router.post("/ah", autohouse);
router.post("/cc", claimscorp);
router.post("/chatter", chatter);
router.post("/kaizen", kaizen);
router.post("/usagereport", usageReport);
router.post("/podium", podium);
module.exports = router;

Some files were not shown because too many files have changed in this diff Show More