feature/IO-3223-canny - Merge release / fix conflicts

This commit is contained in:
Dave Richer
2025-04-25 17:06:44 -04:00
66 changed files with 1957 additions and 3358 deletions

2
.gitignore vendored
View File

@@ -128,3 +128,5 @@ vitest-coverage/
*.vitest.log *.vitest.log
test-output.txt test-output.txt
server/job/test/fixtures server/job/test/fixtures
.github

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,5 @@
import { AlertFilled } from "@ant-design/icons"; import { AlertFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client"; import { useLazyQuery, useMutation } from "@apollo/client";
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd"; import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
import queryString from "query-string"; import queryString from "query-string";
@@ -8,24 +8,30 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries"; import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions"; import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { GenerateDocument } from "../../utils/RenderTemplate"; import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import { TemplateList } from "../../utils/TemplateConstants";
import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.component"; import DataLabel from "../data-label/data-label.component";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import ScheduleManualEvent from "../schedule-manual-event/schedule-manual-event.component"; import ScheduleManualEvent from "../schedule-manual-event/schedule-manual-event.component";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
import ScheduleAtChange from "./job-at-change.component"; import ScheduleAtChange from "./job-at-change.component";
import ScheduleEventColor from "./schedule-event.color.component"; import ScheduleEventColor from "./schedule-event.color.component";
import ScheduleEventNote from "./schedule-event.note.component"; import ScheduleEventNote from "./schedule-event.note.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -33,7 +39,8 @@ const mapStateToProps = createStructuredSelector({
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })), setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })),
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
setMessage: (text) => dispatch(setMessage(text)) setMessage: (text) => dispatch(setMessage(text)),
insertAuditTrail: ({ jobid, operation }) => dispatch(insertAuditTrail({ jobid, operation }))
}); });
export function ScheduleEventComponent({ export function ScheduleEventComponent({
@@ -43,16 +50,41 @@ export function ScheduleEventComponent({
event, event,
refetch, refetch,
handleCancel, handleCancel,
setScheduleContext setScheduleContext,
insertAuditTrail
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const history = useNavigate(); const history = useNavigate();
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT); const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [mutationUpdateJob] = useMutation(JOB_PRODUCTION_TOGGLE);
const [title, setTitle] = useState(event.title); const [title, setTitle] = useState(event.title);
const { socket } = useSocket(); const { socket } = useSocket();
const notification = useNotification(); const notification = useNotification();
const [form] = Form.useForm();
const [popOverVisible, setPopOverVisible] = useState(false);
const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, {
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
? 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
});
}
},
fetchPolicy: "network-only"
});
const blockContent = ( const blockContent = (
<Space direction="vertical" wrap> <Space direction="vertical" wrap>
@@ -89,6 +121,74 @@ export function ScheduleEventComponent({
</Space> </Space>
); );
const handleConvert = async (values) => {
const res = await mutationUpdateJob({
variables: {
jobId: event.job.id,
job: {
...values,
status: bodyshop.md_ro_statuses.default_arrived,
inproduction: true
}
}
});
if (!res.errors) {
notification["success"]({
message: t("jobs.successes.converted")
});
insertAuditTrail({
jobid: event.job.id,
operation: AuditTrailMapping.jobintake(
res.data.update_jobs.returning[0].status,
DateTimeFormatterFunction(values.scheduled_completion)
)
});
setPopOverVisible(false);
refetch();
}
};
const popMenu = (
<div onClick={(e) => e.stopPropagation()}>
<Form layout="vertical" form={form} onFinish={handleConvert}>
<Form.Item
name={["actual_in"]}
label={t("jobs.fields.actual_in")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Form.Item
name={["scheduled_completion"]}
label={t("jobs.fields.scheduled_completion")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Form.Item name={["scheduled_delivery"]} label={t("jobs.fields.scheduled_delivery")}>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Space wrap>
<Button type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</Space>
</Form>
</div>
);
const popoverContent = ( const popoverContent = (
<div style={{ maxWidth: "40vw" }}> <div style={{ maxWidth: "40vw" }}>
{!event.isintake ? ( {!event.isintake ? (
@@ -294,7 +394,7 @@ export function ScheduleEventComponent({
) : ( ) : (
<ScheduleManualEvent event={event} /> <ScheduleManualEvent event={event} />
)} )}
{event.isintake ? ( {event.isintake && HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
<Link <Link
to={{ to={{
pathname: `/manage/jobs/${event.job && event.job.id}/intake`, pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
@@ -303,7 +403,21 @@ export function ScheduleEventComponent({
> >
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button> <Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
</Link> </Link>
) : null} ) : (
<Popover //open={open}
content={popMenu}
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
getJobDetails();
e.stopPropagation();
}}
getPopupContainer={(trigger) => trigger.parentNode}
trigger="click"
>
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
</Popover>
)}
</Space> </Space>
</div> </div>
); );

View File

@@ -1,5 +1,4 @@
import { Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd"; import { Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; 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"> <Form.Item label={t("jobs.fields.tlos_ind")} name="tlos_ind" valuePropName="checked">
<Switch disabled={jobRO} /> <Switch disabled={jobRO} />
</Form.Item> </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> </FormRow>
</Col> </Col>
<Col {...lossColDamage}> <Col {...lossColDamage}>

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; 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 { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
@@ -12,7 +13,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter"; import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component"; 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"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -44,9 +44,16 @@ export function JobsDetailHeaderActionsToggleProduction({
variables: { id: job.id }, variables: { id: job.id },
onCompleted: (data) => { onCompleted: (data) => {
if (data?.jobs_by_pk) { 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({ form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(), 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, actual_completion: data.jobs_by_pk.actual_completion,
scheduled_delivery: data.jobs_by_pk.scheduled_delivery, scheduled_delivery: data.jobs_by_pk.scheduled_delivery,
actual_delivery: data.jobs_by_pk.actual_delivery actual_delivery: data.jobs_by_pk.actual_delivery

View File

@@ -10,6 +10,7 @@ import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import { DateTimeFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.component"; import DataLabel from "../data-label/data-label.component";
@@ -21,7 +22,6 @@ import ProductionListColumnComment from "../production-list-columns/production-l
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component"; import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
import "./jobs-detail-header.styles.scss"; import "./jobs-detail-header.styles.scss";
import dayjs from "../../utils/day";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
@@ -149,6 +149,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
</Space> </Space>
</Tag> </Tag>
)} )}
{job.hit_and_run && (
<Tag color="green">
<Space>
<WarningFilled />
<span>{t("jobs.fields.hit_and_run")}</span>
</Space>
</Tag>
)}
</Space> </Space>
</div> </div>
</Card> </Card>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import { Card, Col, Form, Radio, Row } from "antd"; import { Card, Col, Form, Radio, Row } from "antd";
import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
const LayoutSettings = ({ t }) => ( const LayoutSettings = ({ t }) => (
<Card title={t("production.settings.layout")}> <Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{[ {[
{ {
@@ -48,9 +47,9 @@ const LayoutSettings = ({ t }) => (
] ]
} }
].map(({ name, label, options }) => ( ].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}> <Form.Item name={name} label={label}>
<Radio.Group> <Radio.Group style={{ display: "flex", flexWrap: "nowrap" }}>
{options.map((option) => ( {options.map((option) => (
<Radio.Button key={option.value.toString()} value={option.value}> <Radio.Button key={option.value.toString()} value={option.value}>
{option.label} {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 { DragDropContext, Draggable, Droppable } from "../trello-board/dnd/lib/index.js";
import { statisticsItems } from "./defaultKanbanSettings.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 StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChanges }) => {
const onDragEnd = (result) => { const onDragEnd = (result) => {

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,7 +1,6 @@
import { useLazyQuery } from "@apollo/client"; import { useLazyQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Form, Input, InputNumber, Select, Switch } from "antd"; import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; 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 LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component"; import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import TimeTicketList from "../time-ticket-list/time-ticket-list.component"; import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import JobEmployeeAssignmentsContainer from "./../job-employee-assignments/job-employee-assignments.container";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -319,10 +319,15 @@ export function TimeTicketModalComponent({
} }
export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) { export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) {
const { t } = useTranslation();
if (loading) return <LoadingSkeleton />; if (loading) return <LoadingSkeleton />;
if (!lineTicketData) return null; if (!lineTicketData) return null;
if (!jobid) return null;
return ( 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 <LaborAllocationsTable
jobId={jobid} jobId={jobid}
joblines={lineTicketData.joblines} joblines={lineTicketData.joblines}
@@ -332,6 +337,6 @@ export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideT
{!hideTimeTickets && ( {!hideTimeTickets && (
<TimeTicketList loading={loading} timetickets={jobid ? lineTicketData.timetickets : []} techConsole /> <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 { useMutation, useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Form, Modal, Space } from "antd"; import { Button, Form, Modal, Space } from "antd";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries"; import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries";
import { INSERT_NEW_TIME_TICKET, UPDATE_TIME_TICKET } from "../../graphql/timetickets.queries"; import { INSERT_NEW_TIME_TICKET, UPDATE_TIME_TICKET } from "../../graphql/timetickets.queries";
import { toggleModalVisible } from "../../redux/modals/modals.actions"; import { toggleModalVisible } from "../../redux/modals/modals.actions";
@@ -14,7 +15,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component"; import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import TimeTicketModalComponent from "./time-ticket-modal.component"; import TimeTicketModalComponent from "./time-ticket-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket, timeTicketModal: selectTimeTicket,
@@ -81,7 +81,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
} }
}; };
const handleMutationSuccess = (response) => { const handleMutationSuccess = () => {
notification["success"]({ notification["success"]({
message: t("timetickets.successes.created") message: t("timetickets.successes.created")
}); });
@@ -123,7 +123,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
if (timeTicketModal.open) form.resetFields(); if (timeTicketModal.open) form.resetFields();
}, [timeTicketModal.open, form]); }, [timeTicketModal.open, form]);
const handleFieldsChange = (changedFields, allFields) => { const handleFieldsChange = (changedFields) => {
if (!!changedFields.employeeid && !!EmployeeAutoCompleteData) { if (!!changedFields.employeeid && !!EmployeeAutoCompleteData) {
const emps = EmployeeAutoCompleteData.employees.filter((e) => e.id === changedFields.employeeid); const emps = EmployeeAutoCompleteData.employees.filter((e) => e.id === changedFields.employeeid);
form.setFieldsValue({ form.setFieldsValue({
@@ -182,6 +182,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
</Space> </Space>
} }
destroyOnClose destroyOnClose
id="time-ticket-modal"
> >
<Form <Form
onFinish={handleFinish} onFinish={handleFinish}

View File

@@ -35,6 +35,30 @@ export const GET_LINE_TICKET_BY_PK = gql`
lbr_adjustments lbr_adjustments
converted converted
status 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 } }) { joblines(where: { jobid: { _eq: $id }, removed: { _eq: false } }) {
id id

View File

@@ -423,6 +423,7 @@ export const GET_JOB_BY_PK = gql`
actual_completion actual_completion
actual_delivery actual_delivery
actual_in actual_in
acv_amount
adjustment_bottom_line adjustment_bottom_line
alt_transport alt_transport
area_of_damage area_of_damage
@@ -511,6 +512,7 @@ export const GET_JOB_BY_PK = gql`
est_ph1 est_ph1
flat_rate_ats flat_rate_ats
federal_tax_rate federal_tax_rate
hit_and_run
id id
inproduction inproduction
ins_addr1 ins_addr1
@@ -2570,6 +2572,20 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
actual_completion actual_completion
scheduled_delivery scheduled_delivery
actual_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

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

View File

@@ -601,7 +601,8 @@
"templates": "Templates" "templates": "Templates"
}, },
"ss_configuration": { "ss_configuration": {
"dailyhrslimit": "Daily Incoming Hours Limit" "dailyhrslimit": "Daily Incoming Hours Limit",
"nobusinessdays": "Include Weekends"
}, },
"ssbuckets": { "ssbuckets": {
"color": "Job Color", "color": "Job Color",
@@ -1637,6 +1638,7 @@
"actual_completion": "Actual Completion", "actual_completion": "Actual Completion",
"actual_delivery": "Actual Delivery", "actual_delivery": "Actual Delivery",
"actual_in": "Actual In", "actual_in": "Actual In",
"acv_amount": "ACV Amount",
"adjustment_bottom_line": "Adjustments", "adjustment_bottom_line": "Adjustments",
"adjustmenthours": "Adjustment Hours", "adjustmenthours": "Adjustment Hours",
"alt_transport": "Alt. Trans.", "alt_transport": "Alt. Trans.",
@@ -1765,6 +1767,7 @@
"federal_tax_payable": "Federal Tax Payable", "federal_tax_payable": "Federal Tax Payable",
"federal_tax_rate": "Federal Tax Rate", "federal_tax_rate": "Federal Tax Rate",
"flat_rate_ats": "Flat Rate ATS?", "flat_rate_ats": "Flat Rate ATS?",
"hit_and_run": "Hit and Run",
"ins_addr1": "Insurance Co. Address", "ins_addr1": "Insurance Co. Address",
"ins_city": "Insurance Co. City", "ins_city": "Insurance Co. City",
"ins_co_id": "Insurance Co. ID", "ins_co_id": "Insurance Co. ID",

View File

@@ -601,7 +601,8 @@
"templates": "" "templates": ""
}, },
"ss_configuration": { "ss_configuration": {
"dailyhrslimit": "" "dailyhrslimit": "",
"nobusinessdays": ""
}, },
"ssbuckets": { "ssbuckets": {
"color": "", "color": "",
@@ -1637,6 +1638,7 @@
"actual_completion": "Realización real", "actual_completion": "Realización real",
"actual_delivery": "Entrega real", "actual_delivery": "Entrega real",
"actual_in": "Real en", "actual_in": "Real en",
"acv_amount": "",
"adjustment_bottom_line": "Ajustes", "adjustment_bottom_line": "Ajustes",
"adjustmenthours": "", "adjustmenthours": "",
"alt_transport": "", "alt_transport": "",
@@ -1765,6 +1767,7 @@
"federal_tax_payable": "Impuesto federal por pagar", "federal_tax_payable": "Impuesto federal por pagar",
"federal_tax_rate": "", "federal_tax_rate": "",
"flat_rate_ats": "", "flat_rate_ats": "",
"hit_and_run": "",
"ins_addr1": "Dirección de Insurance Co.", "ins_addr1": "Dirección de Insurance Co.",
"ins_city": "Ciudad de seguros", "ins_city": "Ciudad de seguros",
"ins_co_id": "ID de la compañía de seguros", "ins_co_id": "ID de la compañía de seguros",

View File

@@ -601,7 +601,8 @@
"templates": "" "templates": ""
}, },
"ss_configuration": { "ss_configuration": {
"dailyhrslimit": "" "dailyhrslimit": "",
"nobusinessdays": ""
}, },
"ssbuckets": { "ssbuckets": {
"color": "", "color": "",
@@ -1637,6 +1638,7 @@
"actual_completion": "Achèvement réel", "actual_completion": "Achèvement réel",
"actual_delivery": "Livraison réelle", "actual_delivery": "Livraison réelle",
"actual_in": "En réel", "actual_in": "En réel",
"acv_amount": "",
"adjustment_bottom_line": "Ajustements", "adjustment_bottom_line": "Ajustements",
"adjustmenthours": "", "adjustmenthours": "",
"alt_transport": "", "alt_transport": "",
@@ -1765,6 +1767,7 @@
"federal_tax_payable": "Impôt fédéral à payer", "federal_tax_payable": "Impôt fédéral à payer",
"federal_tax_rate": "", "federal_tax_rate": "",
"flat_rate_ats": "", "flat_rate_ats": "",
"hit_and_run": "",
"ins_addr1": "Adresse Insurance Co.", "ins_addr1": "Adresse Insurance Co.",
"ins_city": "Insurance City", "ins_city": "Insurance City",
"ins_co_id": "ID de la compagnie d'assurance", "ins_co_id": "ID de la compagnie d'assurance",

View File

@@ -15,8 +15,8 @@ const AuditTrailMapping = {
jobchecklist: (type, inproduction, status) => jobchecklist: (type, inproduction, status) =>
i18n.t("audit_trail.messages.jobchecklist", { type, inproduction, status }), i18n.t("audit_trail.messages.jobchecklist", { type, inproduction, status }),
jobconverted: (ro_number) => i18n.t("audit_trail.messages.jobconverted", { ro_number }), jobconverted: (ro_number) => i18n.t("audit_trail.messages.jobconverted", { ro_number }),
jobintake: (status, email, scheduled_completion) => jobintake: (status, scheduled_completion) =>
i18n.t("audit_trail.messages.jobintake", { status, email, scheduled_completion }), i18n.t("audit_trail.messages.jobintake", { status, scheduled_completion }),
jobdelivery: (status, email, actual_completion) => jobdelivery: (status, email, actual_completion) =>
i18n.t("audit_trail.messages.jobdelivery", { status, email, actual_completion }), i18n.t("audit_trail.messages.jobdelivery", { status, email, actual_completion }),
jobexported: () => i18n.t("audit_trail.messages.jobexported"), jobexported: () => i18n.t("audit_trail.messages.jobexported"),

View File

@@ -31,6 +31,15 @@
headers: headers:
- name: x-imex-auth - name: x-imex-auth
value_from_env: DATAPUMP_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 - name: Rome Usage Report
webhook: '{{HASURA_API_URL}}/data/usagereport' webhook: '{{HASURA_API_URL}}/data/usagereport'
schedule: 0 12 * * 5 schedule: 0 12 * * 5

View File

@@ -1005,6 +1005,7 @@
- pbs_configuration - pbs_configuration
- pbs_serialnumber - pbs_serialnumber
- phone - phone
- podiumid
- prodtargethrs - prodtargethrs
- production_config - production_config
- region_config - region_config
@@ -3594,6 +3595,7 @@
- actual_completion - actual_completion
- actual_delivery - actual_delivery
- actual_in - actual_in
- acv_amount
- adj_g_disc - adj_g_disc
- adj_strdis - adj_strdis
- adj_towdis - adj_towdis
@@ -3699,6 +3701,7 @@
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
- hit_and_run
- id - id
- inproduction - inproduction
- ins_addr1 - ins_addr1
@@ -3865,6 +3868,7 @@
- actual_completion - actual_completion
- actual_delivery - actual_delivery
- actual_in - actual_in
- acv_amount
- adj_g_disc - adj_g_disc
- adj_strdis - adj_strdis
- adj_towdis - adj_towdis
@@ -3971,6 +3975,7 @@
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
- hit_and_run
- id - id
- inproduction - inproduction
- ins_addr1 - ins_addr1
@@ -4149,6 +4154,7 @@
- actual_completion - actual_completion
- actual_delivery - actual_delivery
- actual_in - actual_in
- acv_amount
- adj_g_disc - adj_g_disc
- adj_strdis - adj_strdis
- adj_towdis - adj_towdis
@@ -4255,6 +4261,7 @@
- federal_tax_rate - federal_tax_rate
- flat_rate_ats - flat_rate_ats
- g_bett_amt - g_bett_amt
- hit_and_run
- id - id
- inproduction - inproduction
- ins_addr1 - ins_addr1

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

@@ -118,6 +118,7 @@ const applyRoutes = ({ app }) => {
app.use("/csi", require("./server/routes/csiRoutes")); app.use("/csi", require("./server/routes/csiRoutes"));
app.use("/payroll", require("./server/routes/payrollRoutes")); app.use("/payroll", require("./server/routes/payrollRoutes"));
app.use("/sso", require("./server/routes/ssoRoutes")); app.use("/sso", require("./server/routes/ssoRoutes"));
app.use("/integrations", require("./server/routes/intergrationRoutes"));
// Default route for forbidden access // Default route for forbidden access
app.get("/", (req, res) => { app.get("/", (req, res) => {

View File

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

View File

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

View File

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

View File

@@ -4,3 +4,4 @@ exports.chatter = require("./chatter").default;
exports.claimscorp = require("./claimscorp").default; exports.claimscorp = require("./claimscorp").default;
exports.kaizen = require("./kaizen").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"); const { header, end, start } = require("./html");
// Required Strings // Required Strings
@@ -7,19 +5,6 @@ const { header, end, start } = require("./html");
// - subHeader - The subheader of the email // - subHeader - The subheader of the email
// - body - The body 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 * Generate the email template
* @param strings * @param strings
@@ -32,81 +17,48 @@ const generateEmailTemplate = (strings) => {
header + header +
start + start +
` `
<table class="row"> <!-- Report Title -->
<tbody> ${
<tr> strings.header &&
<th class="small-12 large-12 columns first last"> `
<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;">
<tbody> <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> <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;">
<td> <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>
<h6 style="text-align:left"><strong>${strings.header}</strong></h6> </td></tr>
</td> </tbody></table></th>
</tr> </tr></tbody></table>
<tr> `
<td> }
<p style="font-size:90%">${strings.subHeader}</p> ${
</td> strings.subHeader &&
</tr> `
</tbody> <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;">
</table> <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%;">
</th> <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;">
</tr> <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>
</tbody> </td></tr>
</table> </tbody></table></th>
</tr></tbody></table>
`
}
<!-- End Report Title --> <!-- End Report Title -->
<!-- Task Detail --> ${
<table class="row"> strings.body &&
<tbody> `
<tr> <!-- Report Detail -->
<th class="small-12 large-12 columns first last"> <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;">
<table> <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%;">
<tbody> <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;">
<tr> ${strings.body}
<td>${strings.body}</td> </td></tr>
</tr> </tbody></table></th>
</tbody> </tr></tbody></table>
</table> <!-- End Report Detail -->
</th> `
</tr> }
</tbody> ` +
</table> end(strings.dateLine)
<!-- 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
); );
}; };

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, to: req?.body?.to,
cc: req?.body?.cc, cc: req?.body?.cc,
subject: req?.body?.subject, subject: req?.body?.subject,
email email,
errorMessage: error?.message,
errorStack: error?.stack
// info, // info,
}); });
} }
@@ -68,6 +70,7 @@ const sendServerEmail = async ({ subject, text }) => {
] ]
} }
}, },
// eslint-disable-next-line no-unused-vars
(err, info) => { (err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, { logger.log("server-email-failure", err ? "error" : "debug", null, null, {
message: err?.message, 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 }) => { const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => {
try { try {
mailer.sendMail( mailer.sendMail(
@@ -93,6 +198,7 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
...(type === "text" ? { text } : { html }), ...(type === "text" ? { text } : { html }),
attachments: attachments || null attachments: attachments || null
}, },
// eslint-disable-next-line no-unused-vars
(err, info) => { (err, info) => {
// (message, type, user, record, meta // (message, type, user, record, meta
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack }); 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, to: req.body.to,
cc: req.body.cc, cc: req.body.cc,
subject: req.body.subject, subject: req.body.subject,
attachments: attachments: [
[ ...(req.body.attachments &&
...((req.body.attachments && req.body.attachments.map((a) => {
req.body.attachments.map((a) => {
return {
filename: a.filename,
path: a.path
};
})) ||
[]),
...downloadedMedia.map((a) => {
return { 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, html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html,
ses: { ses: {
// optional extra arguments for SendRawEmail // optional extra arguments for SendRawEmail
@@ -273,6 +377,7 @@ ${body.bounce?.bouncedRecipients.map(
)} )}
` `
}, },
// eslint-disable-next-line no-unused-vars
(err, info) => { (err, info) => {
logger.log("sns-error", err ? "error" : "debug", "api", null, { logger.log("sns-error", err ? "error" : "debug", "api", null, {
errorMessage: err?.message, errorMessage: err?.message,
@@ -294,5 +399,6 @@ module.exports = {
sendEmail, sendEmail,
sendServerEmail, sendServerEmail,
sendTaskEmail, sendTaskEmail,
emailBounce emailBounce,
sendWelcomeEmail
}; };

View File

@@ -17,11 +17,13 @@ const { formatTaskPriority } = require("../notifications/stringHelpers");
const tasksEmailQueue = taskEmailQueue(); const tasksEmailQueue = taskEmailQueue();
// Cleanup function for the Tasks Email Queue // Cleanup function for the Tasks Email Queue
// eslint-disable-next-line no-unused-vars
const tasksEmailQueueCleanup = async () => { const tasksEmailQueueCleanup = async () => {
try { try {
// Example async operation // Example async operation
// console.log("Performing Tasks Email Reminder process cleanup..."); // console.log("Performing Tasks Email Reminder process cleanup...");
await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve())); await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve()));
// eslint-disable-next-line no-unused-vars
} catch (err) { } catch (err) {
// console.error("Tasks Email Reminder process cleanup failed:", 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`, header: `${allTasks.length} Tasks require your attention`,
subHeader: `Please click on the Tasks below to view the Task.`, subHeader: `Please click on the Tasks below to view the Task.`,
dateLine, dateLine,
body: `<ul> body: `
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; margin: 1%; padding-left: 30px;">
${allTasks ${allTasks
.map((task) => .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("")} .join("")}
</ul>` </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 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({ admin.initializeApp({
credential: admin.credential.cert(serviceAccount), 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 = { module.exports = {
admin, admin,
createUser, createUser,
@@ -208,23 +292,7 @@ module.exports = {
getUser, getUser,
sendNotification, sendNotification,
subscribe, 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 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. //New bug introduced with Graphql Request.
// https://github.com/prisma-labs/graphql-request/issues/206 // https://github.com/prisma-labs/graphql-request/issues/206
// const { Headers } = require("cross-fetch"); // const { Headers } = require("cross-fetch");
// global.Headers = global.Headers || Headers; // global.Headers = global.Headers || Headers;
exports.client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
headers: { headers: {
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET "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

@@ -1323,6 +1323,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 = ` exports.UPDATE_JOB = `
mutation UPDATE_JOB($jobId: uuid!, $job: jobs_set_input!) { mutation UPDATE_JOB($jobId: uuid!, $job: jobs_set_input!) {
update_jobs(where: { id: { _eq: $jobId } }, _set: $job) { update_jobs(where: { id: { _eq: $jobId } }, _set: $job) {
@@ -1848,6 +1869,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{ exports.DELETE_ALL_DMS_VEHICLES = `mutation DELETE_ALL_DMS_VEHICLES{
delete_dms_vehicles(where: {}) { delete_dms_vehicles(where: {}) {
affected_rows affected_rows
@@ -2853,3 +2884,47 @@ query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) {
email 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
}
}
}
`;

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 // Create payment response record
const responseResults = await gqlClient.request(INSERT_PAYMENT_RESPONSE, { const responseResults = await gqlClient
paymentResponse: { .request(INSERT_PAYMENT_RESPONSE, {
amount: values.total, paymentResponse: {
bodyshopid: bodyshop.id, amount: values.total,
paymentid: paymentResult.id, bodyshopid: bodyshop.id,
jobid: job.id, paymentid: paymentResult.insert_payments.returning[0].id,
declinereason: "Approved", jobid: job.id,
ext_paymentid: values.paymentid, declinereason: "Approved",
successful: true, ext_paymentid: values.paymentid,
response: values 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, { logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, {
responseResults, responseResults,

View File

@@ -1,5 +1,6 @@
const { sendTaskEmail } = require("../../email/sendemail"); const { sendTaskEmail } = require("../../email/sendemail");
const generateEmailTemplate = require("../../email/generateTemplate"); const generateEmailTemplate = require("../../email/generateTemplate");
const { InstanceEndpoints } = require("../../utils/instanceMgr");
/** /**
* @description Send notification email to the user * @description Send notification email to the user
@@ -22,11 +23,9 @@ const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, lo
body: jobs.jobs body: jobs.jobs
.map( .map(
(job) => (job) =>
`Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${ `<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>`
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}`
) )
.join("<br/>") .join("")
}) })
}); });
} catch (error) { } catch (error) {

View File

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

View File

@@ -1,8 +1,12 @@
const path = require("path"); const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const logger = require("../utils/logger"); 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 { const {
S3Client, S3Client,
PutObjectCommand, PutObjectCommand,
@@ -10,31 +14,32 @@ const {
CopyObjectCommand, CopyObjectCommand,
DeleteObjectCommand DeleteObjectCommand
} = require("@aws-sdk/client-s3"); } = 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 { const {
GET_DOCUMENTS_BY_JOB, GET_DOCUMENTS_BY_JOB,
QUERY_TEMPORARY_DOCS, QUERY_TEMPORARY_DOCS,
GET_DOCUMENTS_BY_IDS, GET_DOCUMENTS_BY_IDS,
DELETE_MEDIA_DOCUMENTS DELETE_MEDIA_DOCUMENTS
} = require("../graphql-client/queries"); } = 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 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 imgproxySalt = process.env.IMGPROXY_SALT;
const imgproxyDestinationBucket = process.env.IMGPROXY_DESTINATION_BUCKET; 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. * Generate a Signed URL Link for the s3 bucket.
exports.generateSignedUploadUrls = async (req, res) => { * 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; const { filenames, bodyshopid, jobid } = req.body;
try { 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 = []; const signedUrls = [];
for (const filename of filenames) { for (const filename of filenames) {
@@ -50,24 +55,32 @@ exports.generateSignedUploadUrls = async (req, res) => {
} }
logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls }); logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls });
res.json({
return res.json({
success: true, success: true,
signedUrls signedUrls
}); });
} catch (error) { } catch (error) {
res.status(400).json({ logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, {
success: false,
message: error.message, message: error.message,
stack: error.stack stack: error.stack
}); });
logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, {
return res.status(400).json({
success: false,
message: error.message, message: error.message,
stack: error.stack 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; const { jobid, billid } = req.body;
try { try {
@@ -86,10 +99,11 @@ exports.getThumbnailUrls = async (req, res) => {
for (const document of data.documents) { for (const document of data.documents) {
//Format to follow: //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. //When working with documents from Cloudinary, the URL does not include the extension.
let key; let key;
if (/\.[^/.]+$/.test(document.key)) { if (/\.[^/.]+$/.test(document.key)) {
key = document.key; key = document.key;
} else { } else {
@@ -98,12 +112,12 @@ exports.getThumbnailUrls = async (req, res) => {
// Build the S3 path to the object. // Build the S3 path to the object.
const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`; const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`;
const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path); const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path);
//Thumbnail Generation Block //Thumbnail Generation Block
const thumbProxyPath = `${thumbResizeParams}/${base64UrlEncodedKeyString}`; const thumbProxyPath = `${thumbResizeParams}/${base64UrlEncodedKeyString}`;
const thumbHmacSalt = createHmacSha256(`${imgproxySalt}/${thumbProxyPath}`); const thumbHmacSalt = createHmacSha256(`${imgproxySalt}/${thumbProxyPath}`);
//Full Size URL block //Full Size URL block
const fullSizeProxyPath = `${base64UrlEncodedKeyString}`; const fullSizeProxyPath = `${base64UrlEncodedKeyString}`;
const fullSizeHmacSalt = createHmacSha256(`${imgproxySalt}/${fullSizeProxyPath}`); const fullSizeHmacSalt = createHmacSha256(`${imgproxySalt}/${fullSizeProxyPath}`);
@@ -114,8 +128,8 @@ exports.getThumbnailUrls = async (req, res) => {
Bucket: imgproxyDestinationBucket, Bucket: imgproxyDestinationBucket,
Key: key 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 originalProxyPath = `raw:1/${base64UrlEncodedKeyString}`;
const originalHmacSalt = createHmacSha256(`${imgproxySalt}/${originalProxyPath}`); 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. //Iterate over them, build the link based on the media type, and return the array.
} catch (error) { } catch (error) {
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, { logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, {
@@ -142,57 +156,72 @@ exports.getThumbnailUrls = async (req, res) => {
message: error.message, message: error.message,
stack: error.stack 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. * Download Files
}; * @param req
* @param res
exports.downloadFiles = async (req, 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 //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 { 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. //Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components.
const client = req.userGraphQLClient; const client = req.userGraphQLClient;
//Query for the keys of the document IDs //Query for the keys of the document IDs
const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids }); 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 s3client = new S3Client({ region: InstanceRegion() });
const archiveStream = archiver("zip"); const archiveStream = archiver("zip");
archiveStream.on("error", (error) => { archiveStream.on("error", (error) => {
console.error("Archival encountered an error:", error); console.error("Archival encountered an error:", error);
throw new 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)) { for (const key of data.documents.map((d) => d.key)) {
const response = await s3client.send(new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key })); const response = await s3client.send(
// :: `response.Body` is a Buffer new GetObjectCommand({
console.log(path.basename(key)); Bucket: imgproxyDestinationBucket,
Key: key
})
);
archiveStream.append(response.Body, { name: path.basename(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({ const parallelUploads3 = new Upload({
client: s3client, client: s3client,
queueSize: 4, // optional concurrency configuration queueSize: 4, // optional concurrency configuration
leavePartsOnError: false, // optional manually handle dropped parts 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) => { // Disabled progress logging for upload, uncomment if needed
console.log(progress); // parallelUploads3.on("httpUploadProgress", (progress) => {
}); // console.log(progress);
// });
await parallelUploads3.done();
const uploadResult = await parallelUploads3.done();
//Generate the presigned URL to download it. //Generate the presigned URL to download it.
const presignedUrl = await getSignedUrl( const presignedUrl = await getSignedUrl(
s3client, s3client,
@@ -200,20 +229,27 @@ exports.downloadFiles = async (req, res) => {
{ expiresIn: 360 } { 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. //Iterate over them, build the link based on the media type, and return the array.
} catch (error) { } catch (error) {
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, { logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobId, {
jobid, jobId,
billid, billid,
message: error.message, message: error.message,
stack: error.stack 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 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. //Mark as deleted from the documents section of the database.
const { ids } = req.body; const { ids } = req.body;
@@ -232,7 +268,7 @@ exports.deleteFiles = async (req, res) => {
(async () => { (async () => {
try { try {
// Delete the original object // Delete the original object
const deleteResult = await s3client.send( await s3client.send(
new DeleteObjectCommand({ new DeleteObjectCommand({
Bucket: imgproxyDestinationBucket, Bucket: imgproxyDestinationBucket,
Key: document.key Key: document.key
@@ -250,23 +286,30 @@ exports.deleteFiles = async (req, res) => {
const result = await Promise.all(deleteTransactions); const result = await Promise.all(deleteTransactions);
const errors = result.filter((d) => d.error); 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, { const deleteMutationResult = await client.request(DELETE_MEDIA_DOCUMENTS, {
ids: result.filter((t) => !t.error).map((d) => d.id) ids: result.filter((t) => !t.error).map((d) => d.id)
}); });
res.json({ errors, deleteMutationResult }); return res.json({ errors, deleteMutationResult });
} catch (error) { } catch (error) {
logger.log("imgproxy-delete-files-error", "ERROR", req.user.email, null, { logger.log("imgproxy-delete-files-error", "ERROR", req.user.email, null, {
ids, ids,
message: error.message, message: error.message,
stack: error.stack 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; const { documents, tojobid } = req.body;
try { try {
logger.log("imgproxy-move-files", "DEBUG", req.user.email, null, { documents, tojobid }); logger.log("imgproxy-move-files", "DEBUG", req.user.email, null, { documents, tojobid });
@@ -278,7 +321,7 @@ exports.moveFiles = async (req, res) => {
(async () => { (async () => {
try { try {
// Copy the object to the new key // Copy the object to the new key
const copyresult = await s3client.send( await s3client.send(
new CopyObjectCommand({ new CopyObjectCommand({
Bucket: imgproxyDestinationBucket, Bucket: imgproxyDestinationBucket,
CopySource: `${imgproxyDestinationBucket}/${document.from}`, CopySource: `${imgproxyDestinationBucket}/${document.from}`,
@@ -288,7 +331,7 @@ exports.moveFiles = async (req, res) => {
); );
// Delete the original object // Delete the original object
const deleteResult = await s3client.send( await s3client.send(
new DeleteObjectCommand({ new DeleteObjectCommand({
Bucket: imgproxyDestinationBucket, Bucket: imgproxyDestinationBucket,
Key: document.from Key: document.from
@@ -297,7 +340,12 @@ exports.moveFiles = async (req, res) => {
return document; return document;
} catch (error) { } 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); const errors = result.filter((d) => d.error);
let mutations = ""; let mutations = "";
result result
.filter((d) => !d.error) .filter((d) => !d.error)
.forEach((d, idx) => { .forEach((d, idx) => {
@@ -321,14 +370,16 @@ exports.moveFiles = async (req, res) => {
}); });
const client = req.userGraphQLClient; const client = req.userGraphQLClient;
if (mutations !== "") { if (mutations !== "") {
const mutationResult = await client.request(`mutation { const mutationResult = await client.request(`mutation {
${mutations} ${mutations}
}`); }`);
res.json({ errors, mutationResult });
} else { return res.json({ errors, mutationResult });
res.json({ errors: "No images were succesfully moved on remote server. " });
} }
return res.json({ errors: "No images were successfully moved on remote server. " });
} catch (error) { } catch (error) {
logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, { logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, {
documents, documents,
@@ -336,13 +387,15 @@ exports.moveFiles = async (req, res) => {
message: error.message, message: error.message,
stack: error.stack 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) { module.exports = {
return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); generateSignedUploadUrls,
} getThumbnailUrls,
function createHmacSha256(data) { downloadFiles,
return crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url"); deleteFiles,
} moveFiles
};

View File

@@ -1,42 +1,55 @@
const path = require("path");
const _ = require("lodash"); const _ = require("lodash");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const client = require("../graphql-client/graphql-client").client; 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({ const cloudinary = require("cloudinary").v2;
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
var cloudinary = require("cloudinary").v2;
cloudinary.config(process.env.CLOUDINARY_URL); cloudinary.config(process.env.CLOUDINARY_URL);
/**
* @description Creates a signed upload URL for Cloudinary.
* @param req
* @param res
*/
const createSignedUploadURL = (req, res) => { const createSignedUploadURL = (req, res) => {
logger.log("media-signed-upload", "DEBUG", req.user.email, null, null); 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)); 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 downloadFiles = (req, res) => {
const { ids } = req.body; const { ids } = req.body;
logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null); logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null);
const url = cloudinary.utils.download_zip_url({ const url = cloudinary.utils.download_zip_url({
public_ids: ids, public_ids: ids,
flatten_folders: true flatten_folders: true
}); });
res.send(url); 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 deleteFiles = async (req, res) => {
const { ids } = req.body; 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); logger.log("media-bulk-delete", "DEBUG", req.user.email, ids, null);
const returns = []; const returns = [];
if (types.image) { if (types.image) {
//delete images //delete images
@@ -47,8 +60,8 @@ const deleteFiles = async (req, res) => {
) )
); );
} }
if (types.video) { if (types.video) {
//delete images returns.push(
returns.push( returns.push(
await cloudinary.api.delete_resources( await cloudinary.api.delete_resources(
types.video.map((x) => x.key), types.video.map((x) => x.key),
@@ -56,8 +69,8 @@ const deleteFiles = async (req, res) => {
) )
); );
} }
if (types.raw) { if (types.raw) {
//delete images returns.push(
returns.push( returns.push(
await cloudinary.api.delete_resources( await cloudinary.api.delete_resources(
types.raw.map((x) => `${x.key}.${x.extension}`), types.raw.map((x) => `${x.key}.${x.extension}`),
@@ -68,6 +81,7 @@ const deleteFiles = async (req, res) => {
// Delete it on apollo. // Delete it on apollo.
const successfulDeletes = []; const successfulDeletes = [];
returns.forEach((resType) => { returns.forEach((resType) => {
Object.keys(resType.deleted).forEach((key) => { Object.keys(resType.deleted).forEach((key) => {
if (resType.deleted[key] === "deleted" || resType.deleted[key] === "not_found") { if (resType.deleted[key] === "deleted" || resType.deleted[key] === "not_found") {
@@ -77,7 +91,7 @@ const deleteFiles = async (req, res) => {
}); });
try { 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) 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 renameKeys = async (req, res) => {
const { documents, tojobid } = req.body; const { documents, tojobid } = req.body;
logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents); logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents);
const proms = []; const proms = [];
documents.forEach((d) => { documents.forEach((d) => {
proms.push( proms.push(
(async () => { (async () => {
try { try {
const res = { return {
id: d.id, id: d.id,
...(await cloudinary.uploader.rename(d.from, d.to, { ...(await cloudinary.uploader.rename(d.from, d.to, {
resource_type: DetermineFileType(d.type) resource_type: determineFileType(d.type)
})) }))
}; };
return res;
} catch (error) { } catch (error) {
return { id: d.id, from: d.from, error: error }; return { id: d.id, from: d.from, error: error };
} }
@@ -148,18 +167,13 @@ const renameKeys = async (req, res) => {
}`); }`);
res.json({ errors, mutationResult }); res.json({ errors, mutationResult });
} else { } 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. module.exports = {
function DetermineFileType(filetype) { createSignedUploadURL,
if (!filetype) return "auto"; downloadFiles,
else if (filetype.startsWith("image")) return "image"; deleteFiles,
else if (filetype.startsWith("video")) return "video"; renameKeys
else if (filetype.startsWith("application/pdf")) return "image"; };
else if (filetype.startsWith("application")) return "raw";
return "auto";
}

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

@@ -133,11 +133,19 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
subHeader: `Dear ${firstName},`, subHeader: `Dear ${firstName},`,
dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"), dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"),
body: ` body: `
<p>There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p><br/> <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>
<ul> </td></tr></table></th>
${messages.map((msg) => `<li>${msg}</li>`).join("")} </tr></tbody></table>
</ul><br/><br/> <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;">
<p><a href="${InstanceEndpoints()}/manage/jobs/${jobId}">Please check the job for more details.</a></p> <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({ await sendTaskEmail({
@@ -226,6 +234,7 @@ const getQueue = () => {
* @param {Object} options.logger - Logger instance for logging dispatch events. * @param {Object} options.logger - Logger instance for logging dispatch events.
* @returns {Promise<void>} Resolves when all notifications are added to the queue. * @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 dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => {
const emailAddQueue = getQueue(); const emailAddQueue = getQueue();

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
const express = require("express"); const express = require("express");
const router = express.Router(); 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("/ah", autohouse);
router.post("/cc", claimscorp); router.post("/cc", claimscorp);
router.post("/chatter", chatter); router.post("/chatter", chatter);
router.post("/kaizen", kaizen); router.post("/kaizen", kaizen);
router.post("/usagereport", usageReport); router.post("/usagereport", usageReport);
router.post("/podium", podium);
module.exports = router; module.exports = router;

View File

@@ -0,0 +1,8 @@
const express = require("express");
const vsstaIntegration = require("../integrations/VSSTA/vsstaIntegrationRoute");
const vsstaMiddleware = require("../middleware/vsstaIntegrationMiddleware");
const router = express.Router();
router.post("/vssta", vsstaMiddleware, vsstaIntegration);
module.exports = router;

View File

@@ -1,6 +1,5 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const job = require("../job/job");
const ppc = require("../ccc/partspricechange"); const ppc = require("../ccc/partspricechange");
const { partsScan } = require("../parts-scan/parts-scan"); const { partsScan } = require("../parts-scan/parts-scan");
const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware"); const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware");