feature/IO-3223-canny - Merge release / fix conflicts
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -128,3 +128,5 @@ vitest-coverage/
|
||||
*.vitest.log
|
||||
test-output.txt
|
||||
server/job/test/fixtures
|
||||
|
||||
.github
|
||||
|
||||
764
_reference/localEmailViewer/package-lock.json
generated
764
_reference/localEmailViewer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,8 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"express": "^4.21.1",
|
||||
"mailparser": "^3.7.1",
|
||||
"express": "^5.1.0",
|
||||
"mailparser": "^3.7.2",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 parsePhoneNumber from "libphonenumber-js";
|
||||
import queryString from "query-string";
|
||||
@@ -8,24 +8,30 @@ import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
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 { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
||||
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import ChatOpenButton from "../chat-open-button/chat-open-button.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 ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.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 ScheduleEventColor from "./schedule-event.color.component";
|
||||
import ScheduleEventNote from "./schedule-event.note.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -33,7 +39,8 @@ const mapStateToProps = createStructuredSelector({
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })),
|
||||
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({
|
||||
@@ -43,16 +50,41 @@ export function ScheduleEventComponent({
|
||||
event,
|
||||
refetch,
|
||||
handleCancel,
|
||||
setScheduleContext
|
||||
setScheduleContext,
|
||||
insertAuditTrail
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const history = useNavigate();
|
||||
const searchParams = queryString.parse(useLocation().search);
|
||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||
const [mutationUpdateJob] = useMutation(JOB_PRODUCTION_TOGGLE);
|
||||
const [title, setTitle] = useState(event.title);
|
||||
const { socket } = useSocket();
|
||||
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 = (
|
||||
<Space direction="vertical" wrap>
|
||||
@@ -89,6 +121,74 @@ export function ScheduleEventComponent({
|
||||
</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 = (
|
||||
<div style={{ maxWidth: "40vw" }}>
|
||||
{!event.isintake ? (
|
||||
@@ -294,7 +394,7 @@ export function ScheduleEventComponent({
|
||||
) : (
|
||||
<ScheduleManualEvent event={event} />
|
||||
)}
|
||||
{event.isintake ? (
|
||||
{event.isintake && HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
|
||||
<Link
|
||||
to={{
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -188,6 +187,12 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
<Form.Item label={t("jobs.fields.tlos_ind")} name="tlos_ind" valuePropName="checked">
|
||||
<Switch disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.hit_and_run")} name="hit_and_run" valuePropName="checked">
|
||||
<Switch disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.acv_amount")} name="acv_amount">
|
||||
<CurrencyInput disabled={jobRO} min={0} />
|
||||
</Form.Item>
|
||||
</FormRow>
|
||||
</Col>
|
||||
<Col {...lossColDamage}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||
@@ -12,7 +13,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
|
||||
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
@@ -44,9 +44,16 @@ export function JobsDetailHeaderActionsToggleProduction({
|
||||
variables: { id: job.id },
|
||||
onCompleted: (data) => {
|
||||
if (data?.jobs_by_pk) {
|
||||
const totalHours =
|
||||
(data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) +
|
||||
(data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0);
|
||||
form.setFieldsValue({
|
||||
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(),
|
||||
scheduled_completion: data.jobs_by_pk.scheduled_completion,
|
||||
scheduled_completion: data.jobs_by_pk.scheduled_completion
|
||||
? data.jobs_by_pk.scheduled_completion
|
||||
: totalHours && bodyshop.ss_configuration.nobusinessdays
|
||||
? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day")
|
||||
: dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"),
|
||||
actual_completion: data.jobs_by_pk.actual_completion,
|
||||
scheduled_delivery: data.jobs_by_pk.scheduled_delivery,
|
||||
actual_delivery: data.jobs_by_pk.actual_delivery
|
||||
|
||||
@@ -10,6 +10,7 @@ import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
||||
import { DateTimeFormatter } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
|
||||
import DataLabel from "../data-label/data-label.component";
|
||||
@@ -21,7 +22,6 @@ import ProductionListColumnComment from "../production-list-columns/production-l
|
||||
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
|
||||
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
|
||||
import "./jobs-detail-header.styles.scss";
|
||||
import dayjs from "../../utils/day";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
jobRO: selectJobReadOnly,
|
||||
@@ -149,6 +149,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
|
||||
</Space>
|
||||
</Tag>
|
||||
)}
|
||||
{job.hit_and_run && (
|
||||
<Tag color="green">
|
||||
<Space>
|
||||
<WarningFilled />
|
||||
<span>{t("jobs.fields.hit_and_run")}</span>
|
||||
</Space>
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Button, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import cleanAxios from "../../utils/CleanAxios";
|
||||
import formatBytes from "../../utils/formatbytes";
|
||||
//import yauzl from "yauzl";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
@@ -28,7 +26,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton);
|
||||
|
||||
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier }) {
|
||||
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier, jobId }) {
|
||||
const { t } = useTranslation();
|
||||
const [download, setDownload] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -46,6 +44,7 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function standardMediaDownload(bufferData) {
|
||||
const a = document.createElement("a");
|
||||
const url = window.URL.createObjectURL(new Blob([bufferData]));
|
||||
@@ -53,13 +52,14 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
|
||||
a.download = `${identifier || "documents"}.zip`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
logImEXEvent("jobs_documents_download");
|
||||
setLoading(true);
|
||||
const zipUrl = await axios({
|
||||
url: "/media/imgproxy/download",
|
||||
method: "POST",
|
||||
data: { documentids: imagesToDownload.map((_) => _.id) }
|
||||
data: { jobId, documentids: imagesToDownload.map((_) => _.id) }
|
||||
});
|
||||
|
||||
const theDownloadedZip = await cleanAxios({
|
||||
|
||||
@@ -75,7 +75,7 @@ function JobsDocumentsImgproxyComponent({
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
|
||||
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} />
|
||||
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
|
||||
<JobsDocumentsDeleteButton
|
||||
galleryImages={galleryImages}
|
||||
deletionCallback={billsCallback || fetchThumbnails || refetch}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Card, Form, Select } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PropTypes from "prop-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const FilterSettings = ({
|
||||
selectedMdInsCos,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Card, Checkbox, Col, Form, Row } from "antd";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const InformationSettings = ({ t }) => (
|
||||
<Card title={t("production.settings.information")}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Card title={t("production.settings.information")} style={{ maxWidth: "100%", overflowX: "auto" }}>
|
||||
<Row gutter={[16, 16]} wrap>
|
||||
{[
|
||||
"model_info",
|
||||
"ownr_nm",
|
||||
@@ -21,7 +20,7 @@ const InformationSettings = ({ t }) => (
|
||||
"subtotal",
|
||||
"tasks"
|
||||
].map((item) => (
|
||||
<Col span={4} key={item}>
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={item}>
|
||||
<Form.Item name={item} valuePropName="checked">
|
||||
<Checkbox>{t(`production.labels.${item}`)}</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Card, Col, Form, Radio, Row } from "antd";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const LayoutSettings = ({ t }) => (
|
||||
<Card title={t("production.settings.layout")}>
|
||||
<Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{[
|
||||
{
|
||||
@@ -48,9 +47,9 @@ const LayoutSettings = ({ t }) => (
|
||||
]
|
||||
}
|
||||
].map(({ name, label, options }) => (
|
||||
<Col span={4} key={name}>
|
||||
<Col xs={24} sm={16} md={10} lg={8} key={name}>
|
||||
<Form.Item name={name} label={label}>
|
||||
<Radio.Group>
|
||||
<Radio.Group style={{ display: "flex", flexWrap: "nowrap" }}>
|
||||
{options.map((option) => (
|
||||
<Radio.Button key={option.value.toString()} value={option.value}>
|
||||
{option.label}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Card, Checkbox, Form } from "antd";
|
||||
import PropTypes from "prop-types";
|
||||
import { DragDropContext, Draggable, Droppable } from "../trello-board/dnd/lib/index.js";
|
||||
import { statisticsItems } from "./defaultKanbanSettings.js";
|
||||
import { Card, Checkbox, Form } from "antd";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChanges }) => {
|
||||
const onDragEnd = (result) => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { SettingOutlined } from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Button, Card, Col, Form, Popover, Row, Tabs } from "antd";
|
||||
import { isFunction } from "lodash";
|
||||
import PropTypes from "prop-types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx";
|
||||
import { UPDATE_KANBAN_SETTINGS } from "../../../graphql/user.queries.js";
|
||||
import { defaultKanbanSettings, mergeWithDefaults } from "./defaultKanbanSettings.js";
|
||||
import LayoutSettings from "./LayoutSettings.jsx";
|
||||
import InformationSettings from "./InformationSettings.jsx";
|
||||
import StatisticsSettings from "./StatisticsSettings.jsx";
|
||||
import FilterSettings from "./FilterSettings.jsx";
|
||||
import PropTypes from "prop-types";
|
||||
import { isFunction } from "lodash";
|
||||
import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx";
|
||||
import { SettingOutlined } from "@ant-design/icons";
|
||||
import InformationSettings from "./InformationSettings.jsx";
|
||||
import LayoutSettings from "./LayoutSettings.jsx";
|
||||
import StatisticsSettings from "./StatisticsSettings.jsx";
|
||||
|
||||
function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bodyshop, data, onSettingsChange }) {
|
||||
const [form] = Form.useForm();
|
||||
@@ -87,7 +87,7 @@ function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bod
|
||||
};
|
||||
|
||||
const overlay = (
|
||||
<Card style={{ minWidth: "80vw" }}>
|
||||
<Card style={{ maxWidth: "80vw", width: "100%"}}>
|
||||
<Form form={form} onFinish={handleFinish} layout="vertical" onValuesChange={handleValuesChange}>
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
|
||||
@@ -54,6 +54,9 @@ export function ProfileShopsContainer({ bodyshop, currentUser }) {
|
||||
|
||||
//Force window refresh.
|
||||
|
||||
//Ping the new partner to refresh.
|
||||
axios.post("http://localhost:1337/refresh");
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Col, Form, Input, Row, Select, Space, Switch, Typography } from "antd";
|
||||
import axios from "axios";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -8,16 +8,16 @@ import { calculateScheduleLoad } from "../../redux/application/application.actio
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { DateFormatter } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import BlurWrapper from "../feature-wrapper/blur-wrapper.component";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
|
||||
import EmailInput from "../form-items-formatted/email-form-item.component";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||
import ScheduleDayViewContainer from "../schedule-day-view/schedule-day-view.container";
|
||||
import ScheduleExistingAppointmentsList from "../schedule-existing-appointments-list/schedule-existing-appointments-list.component";
|
||||
import "./schedule-job-modal.scss";
|
||||
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||
import BlurWrapper from "../feature-wrapper/blur-wrapper.component";
|
||||
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||
import "./schedule-job-modal.scss";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -60,10 +60,12 @@ export function ScheduleJobModalComponent({
|
||||
const totalHours =
|
||||
lbrHrsData.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs + lbrHrsData.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs;
|
||||
|
||||
if (values.start && !values.scheduled_completion)
|
||||
form.setFieldsValue({
|
||||
scheduled_completion: dayjs(values.start).businessDaysAdd(totalHours / bodyshop.target_touchtime, "day")
|
||||
});
|
||||
if (values.start && !values.scheduled_completion) {
|
||||
const addDays = bodyshop.ss_configuration.nobusinessdays
|
||||
? dayjs(values.start).add(totalHours / (bodyshop.target_touchtime || 1), "day")
|
||||
: dayjs(values.start).businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day");
|
||||
form.setFieldsValue({ scheduled_completion: addDays });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -906,6 +906,7 @@ export function ShopInfoGeneral({ form, bodyshop }) {
|
||||
add();
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
id="insurancecos-add-button"
|
||||
>
|
||||
{t("general.actions.add")}
|
||||
</Button>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { ColorPicker } from "./shop-info.rostatus.component";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
@@ -78,6 +77,13 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["ss_configuration", "nobusinessdays"]}
|
||||
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_lost_sale_reasons"]}
|
||||
label={t("bodyshop.fields.md_lost_sale_reasons")}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useLazyQuery } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Form, Input, InputNumber, Select, Switch } from "antd";
|
||||
import React from "react";
|
||||
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -19,6 +18,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
|
||||
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
|
||||
import JobEmployeeAssignmentsContainer from "./../job-employee-assignments/job-employee-assignments.container";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -319,10 +319,15 @@ export function TimeTicketModalComponent({
|
||||
}
|
||||
|
||||
export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) return <LoadingSkeleton />;
|
||||
if (!lineTicketData) return null;
|
||||
if (!jobid) return null;
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}>
|
||||
<JobEmployeeAssignmentsContainer job={lineTicketData.jobs_by_pk} />
|
||||
</Card>
|
||||
<LaborAllocationsTable
|
||||
jobId={jobid}
|
||||
joblines={lineTicketData.joblines}
|
||||
@@ -332,6 +337,6 @@ export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideT
|
||||
{!hideTimeTickets && (
|
||||
<TimeTicketList loading={loading} timetickets={jobid ? lineTicketData.timetickets : []} techConsole />
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Button, Form, Modal, Space } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { QUERY_ACTIVE_EMPLOYEES } from "../../graphql/employees.queries";
|
||||
import { INSERT_NEW_TIME_TICKET, UPDATE_TIME_TICKET } from "../../graphql/timetickets.queries";
|
||||
import { toggleModalVisible } from "../../redux/modals/modals.actions";
|
||||
@@ -14,7 +15,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import dayjs from "../../utils/day";
|
||||
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
|
||||
import TimeTicketModalComponent from "./time-ticket-modal.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
timeTicketModal: selectTimeTicket,
|
||||
@@ -81,7 +81,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
|
||||
}
|
||||
};
|
||||
|
||||
const handleMutationSuccess = (response) => {
|
||||
const handleMutationSuccess = () => {
|
||||
notification["success"]({
|
||||
message: t("timetickets.successes.created")
|
||||
});
|
||||
@@ -123,7 +123,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
|
||||
if (timeTicketModal.open) form.resetFields();
|
||||
}, [timeTicketModal.open, form]);
|
||||
|
||||
const handleFieldsChange = (changedFields, allFields) => {
|
||||
const handleFieldsChange = (changedFields) => {
|
||||
if (!!changedFields.employeeid && !!EmployeeAutoCompleteData) {
|
||||
const emps = EmployeeAutoCompleteData.employees.filter((e) => e.id === changedFields.employeeid);
|
||||
form.setFieldsValue({
|
||||
@@ -182,6 +182,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
|
||||
</Space>
|
||||
}
|
||||
destroyOnClose
|
||||
id="time-ticket-modal"
|
||||
>
|
||||
<Form
|
||||
onFinish={handleFinish}
|
||||
|
||||
@@ -35,6 +35,30 @@ export const GET_LINE_TICKET_BY_PK = gql`
|
||||
lbr_adjustments
|
||||
converted
|
||||
status
|
||||
employee_body
|
||||
employee_body_rel {
|
||||
id
|
||||
first_name
|
||||
last_name
|
||||
}
|
||||
employee_csr
|
||||
employee_csr_rel {
|
||||
id
|
||||
first_name
|
||||
last_name
|
||||
}
|
||||
employee_prep
|
||||
employee_prep_rel {
|
||||
id
|
||||
first_name
|
||||
last_name
|
||||
}
|
||||
employee_refinish
|
||||
employee_refinish_rel {
|
||||
id
|
||||
first_name
|
||||
last_name
|
||||
}
|
||||
}
|
||||
joblines(where: { jobid: { _eq: $id }, removed: { _eq: false } }) {
|
||||
id
|
||||
|
||||
@@ -423,6 +423,7 @@ export const GET_JOB_BY_PK = gql`
|
||||
actual_completion
|
||||
actual_delivery
|
||||
actual_in
|
||||
acv_amount
|
||||
adjustment_bottom_line
|
||||
alt_transport
|
||||
area_of_damage
|
||||
@@ -511,6 +512,7 @@ export const GET_JOB_BY_PK = gql`
|
||||
est_ph1
|
||||
flat_rate_ats
|
||||
federal_tax_rate
|
||||
hit_and_run
|
||||
id
|
||||
inproduction
|
||||
ins_addr1
|
||||
@@ -2570,6 +2572,20 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
|
||||
actual_completion
|
||||
scheduled_delivery
|
||||
actual_delivery
|
||||
labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) {
|
||||
aggregate {
|
||||
sum {
|
||||
mod_lb_hrs
|
||||
}
|
||||
}
|
||||
}
|
||||
larhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _eq: "LAR" } }, { removed: { _eq: false } }] }) {
|
||||
aggregate {
|
||||
sum {
|
||||
mod_lb_hrs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import FingerprintJS from "@fingerprintjs/fingerprintjs";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { notification } from "antd";
|
||||
import axios from "axios";
|
||||
import { setUserId, setUserProperties } from "@firebase/analytics";
|
||||
import {
|
||||
checkActionCode,
|
||||
@@ -12,6 +9,9 @@ import {
|
||||
} from "@firebase/auth";
|
||||
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore";
|
||||
import { getToken } from "@firebase/messaging";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { notification } from "antd";
|
||||
import axios from "axios";
|
||||
import i18next from "i18next";
|
||||
import LogRocket from "logrocket";
|
||||
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
|
||||
@@ -351,7 +351,14 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
|
||||
});
|
||||
payload.features?.allAccess === true
|
||||
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
|
||||
: window.$crisp.push(["set", "session:segments", [["basic"]]]);
|
||||
: (() => {
|
||||
const featureKeys = Object.keys(payload.features).filter(
|
||||
(key) =>
|
||||
payload.features[key] === true ||
|
||||
(typeof payload.features[key] === "string" && !isNaN(Date.parse(payload.features[key])))
|
||||
);
|
||||
window.$crisp.push(["set", "session:segments", [["basic", ...featureKeys]]]);
|
||||
})();
|
||||
} catch (error) {
|
||||
console.warn("Couldnt find $crisp.", error.message);
|
||||
}
|
||||
|
||||
@@ -601,7 +601,8 @@
|
||||
"templates": "Templates"
|
||||
},
|
||||
"ss_configuration": {
|
||||
"dailyhrslimit": "Daily Incoming Hours Limit"
|
||||
"dailyhrslimit": "Daily Incoming Hours Limit",
|
||||
"nobusinessdays": "Include Weekends"
|
||||
},
|
||||
"ssbuckets": {
|
||||
"color": "Job Color",
|
||||
@@ -1637,6 +1638,7 @@
|
||||
"actual_completion": "Actual Completion",
|
||||
"actual_delivery": "Actual Delivery",
|
||||
"actual_in": "Actual In",
|
||||
"acv_amount": "ACV Amount",
|
||||
"adjustment_bottom_line": "Adjustments",
|
||||
"adjustmenthours": "Adjustment Hours",
|
||||
"alt_transport": "Alt. Trans.",
|
||||
@@ -1765,6 +1767,7 @@
|
||||
"federal_tax_payable": "Federal Tax Payable",
|
||||
"federal_tax_rate": "Federal Tax Rate",
|
||||
"flat_rate_ats": "Flat Rate ATS?",
|
||||
"hit_and_run": "Hit and Run",
|
||||
"ins_addr1": "Insurance Co. Address",
|
||||
"ins_city": "Insurance Co. City",
|
||||
"ins_co_id": "Insurance Co. ID",
|
||||
|
||||
@@ -601,7 +601,8 @@
|
||||
"templates": ""
|
||||
},
|
||||
"ss_configuration": {
|
||||
"dailyhrslimit": ""
|
||||
"dailyhrslimit": "",
|
||||
"nobusinessdays": ""
|
||||
},
|
||||
"ssbuckets": {
|
||||
"color": "",
|
||||
@@ -1637,6 +1638,7 @@
|
||||
"actual_completion": "Realización real",
|
||||
"actual_delivery": "Entrega real",
|
||||
"actual_in": "Real en",
|
||||
"acv_amount": "",
|
||||
"adjustment_bottom_line": "Ajustes",
|
||||
"adjustmenthours": "",
|
||||
"alt_transport": "",
|
||||
@@ -1765,6 +1767,7 @@
|
||||
"federal_tax_payable": "Impuesto federal por pagar",
|
||||
"federal_tax_rate": "",
|
||||
"flat_rate_ats": "",
|
||||
"hit_and_run": "",
|
||||
"ins_addr1": "Dirección de Insurance Co.",
|
||||
"ins_city": "Ciudad de seguros",
|
||||
"ins_co_id": "ID de la compañía de seguros",
|
||||
|
||||
@@ -601,7 +601,8 @@
|
||||
"templates": ""
|
||||
},
|
||||
"ss_configuration": {
|
||||
"dailyhrslimit": ""
|
||||
"dailyhrslimit": "",
|
||||
"nobusinessdays": ""
|
||||
},
|
||||
"ssbuckets": {
|
||||
"color": "",
|
||||
@@ -1637,6 +1638,7 @@
|
||||
"actual_completion": "Achèvement réel",
|
||||
"actual_delivery": "Livraison réelle",
|
||||
"actual_in": "En réel",
|
||||
"acv_amount": "",
|
||||
"adjustment_bottom_line": "Ajustements",
|
||||
"adjustmenthours": "",
|
||||
"alt_transport": "",
|
||||
@@ -1765,6 +1767,7 @@
|
||||
"federal_tax_payable": "Impôt fédéral à payer",
|
||||
"federal_tax_rate": "",
|
||||
"flat_rate_ats": "",
|
||||
"hit_and_run": "",
|
||||
"ins_addr1": "Adresse Insurance Co.",
|
||||
"ins_city": "Insurance City",
|
||||
"ins_co_id": "ID de la compagnie d'assurance",
|
||||
|
||||
@@ -15,8 +15,8 @@ const AuditTrailMapping = {
|
||||
jobchecklist: (type, inproduction, status) =>
|
||||
i18n.t("audit_trail.messages.jobchecklist", { type, inproduction, status }),
|
||||
jobconverted: (ro_number) => i18n.t("audit_trail.messages.jobconverted", { ro_number }),
|
||||
jobintake: (status, email, scheduled_completion) =>
|
||||
i18n.t("audit_trail.messages.jobintake", { status, email, scheduled_completion }),
|
||||
jobintake: (status, scheduled_completion) =>
|
||||
i18n.t("audit_trail.messages.jobintake", { status, scheduled_completion }),
|
||||
jobdelivery: (status, email, actual_completion) =>
|
||||
i18n.t("audit_trail.messages.jobdelivery", { status, email, actual_completion }),
|
||||
jobexported: () => i18n.t("audit_trail.messages.jobexported"),
|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
- name: Podium Data Pump
|
||||
webhook: '{{HASURA_API_URL}}/data/podium'
|
||||
schedule: 15 5 * * *
|
||||
include_in_metadata: true
|
||||
payload: {}
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
comment: ""
|
||||
- name: Rome Usage Report
|
||||
webhook: '{{HASURA_API_URL}}/data/usagereport'
|
||||
schedule: 0 12 * * 5
|
||||
|
||||
@@ -1005,6 +1005,7 @@
|
||||
- pbs_configuration
|
||||
- pbs_serialnumber
|
||||
- phone
|
||||
- podiumid
|
||||
- prodtargethrs
|
||||
- production_config
|
||||
- region_config
|
||||
@@ -3594,6 +3595,7 @@
|
||||
- actual_completion
|
||||
- actual_delivery
|
||||
- actual_in
|
||||
- acv_amount
|
||||
- adj_g_disc
|
||||
- adj_strdis
|
||||
- adj_towdis
|
||||
@@ -3699,6 +3701,7 @@
|
||||
- federal_tax_rate
|
||||
- flat_rate_ats
|
||||
- g_bett_amt
|
||||
- hit_and_run
|
||||
- id
|
||||
- inproduction
|
||||
- ins_addr1
|
||||
@@ -3865,6 +3868,7 @@
|
||||
- actual_completion
|
||||
- actual_delivery
|
||||
- actual_in
|
||||
- acv_amount
|
||||
- adj_g_disc
|
||||
- adj_strdis
|
||||
- adj_towdis
|
||||
@@ -3971,6 +3975,7 @@
|
||||
- federal_tax_rate
|
||||
- flat_rate_ats
|
||||
- g_bett_amt
|
||||
- hit_and_run
|
||||
- id
|
||||
- inproduction
|
||||
- ins_addr1
|
||||
@@ -4149,6 +4154,7 @@
|
||||
- actual_completion
|
||||
- actual_delivery
|
||||
- actual_in
|
||||
- acv_amount
|
||||
- adj_g_disc
|
||||
- adj_strdis
|
||||
- adj_towdis
|
||||
@@ -4255,6 +4261,7 @@
|
||||
- federal_tax_rate
|
||||
- flat_rate_ats
|
||||
- g_bett_amt
|
||||
- hit_and_run
|
||||
- id
|
||||
- inproduction
|
||||
- ins_addr1
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."bodyshops" add column "podiumid" text
|
||||
null;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."jobs" add column "hit_and_run" boolean
|
||||
null default 'false';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."jobs" add column "acv_amount" numeric
|
||||
null;
|
||||
@@ -118,6 +118,7 @@ const applyRoutes = ({ app }) => {
|
||||
app.use("/csi", require("./server/routes/csiRoutes"));
|
||||
app.use("/payroll", require("./server/routes/payrollRoutes"));
|
||||
app.use("/sso", require("./server/routes/ssoRoutes"));
|
||||
app.use("/integrations", require("./server/routes/intergrationRoutes"));
|
||||
|
||||
// Default route for forbidden access
|
||||
app.get("/", (req, res) => {
|
||||
|
||||
@@ -217,7 +217,7 @@ exports.PbsExportAp = async function (socket, { billids, txEnvelope }) {
|
||||
|
||||
socket.emit("ap-export-success", billid);
|
||||
} else {
|
||||
CdkBase.createLogEvent(socket, "ERROR", `Export was not succesful.`);
|
||||
CdkBase.createLogEvent(socket, "ERROR", `Export was not successful.`);
|
||||
socket.emit("ap-export-failure", {
|
||||
billid,
|
||||
error: AccountPostingChange.Message
|
||||
|
||||
@@ -105,14 +105,14 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte
|
||||
|
||||
socket.emit("export-success", socket.JobData.id);
|
||||
} else {
|
||||
CdkBase.createLogEvent(socket, "ERROR", `Export was not succesful.`);
|
||||
CdkBase.createLogEvent(socket, "ERROR", `Export was not successful.`);
|
||||
}
|
||||
} catch (error) {
|
||||
CdkBase.createLogEvent(socket, "ERROR", `Error encountered in CdkSelectedCustomer. ${error}`);
|
||||
await InsertFailedExportLog(socket, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Was Successful
|
||||
async function CheckForErrors(socket, response) {
|
||||
if (response.WasSuccessful === undefined || response.WasSuccessful === true) {
|
||||
CdkBase.createLogEvent(socket, "DEBUG", `Successful response from DMS. ${response.Message || ""}`);
|
||||
|
||||
@@ -2,7 +2,6 @@ const path = require("path");
|
||||
const queries = require("../graphql-client/queries");
|
||||
const moment = require("moment-timezone");
|
||||
const converter = require("json-2-csv");
|
||||
const _ = require("lodash");
|
||||
const logger = require("../utils/logger");
|
||||
const fs = require("fs");
|
||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||
|
||||
@@ -4,3 +4,4 @@ exports.chatter = require("./chatter").default;
|
||||
exports.claimscorp = require("./claimscorp").default;
|
||||
exports.kaizen = require("./kaizen").default;
|
||||
exports.usageReport = require("./usageReport").default;
|
||||
exports.podium = require("./podium").default;
|
||||
211
server/data/podium.js
Normal file
211
server/data/podium.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
const moment = require("moment");
|
||||
const { default: RenderInstanceManager } = require("../utils/instanceMgr");
|
||||
const { header, end, start } = require("./html");
|
||||
|
||||
// Required Strings
|
||||
@@ -7,19 +5,6 @@ const { header, end, start } = require("./html");
|
||||
// - subHeader - The subheader of the email
|
||||
// - body - The body of the email
|
||||
|
||||
// Optional Strings (Have default values)
|
||||
// - footer - The footer of the email
|
||||
// - dateLine - The date line of the email
|
||||
|
||||
const defaultFooter = () => {
|
||||
return RenderInstanceManager({
|
||||
imex: "ImEX Online Collision Repair Management System",
|
||||
rome: "Rome Technologies"
|
||||
});
|
||||
};
|
||||
|
||||
const now = () => moment().format("MM/DD/YYYY @ hh:mm a");
|
||||
|
||||
/**
|
||||
* Generate the email template
|
||||
* @param strings
|
||||
@@ -32,81 +17,48 @@ const generateEmailTemplate = (strings) => {
|
||||
header +
|
||||
start +
|
||||
`
|
||||
<table class="row">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small-12 large-12 columns first last">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<h6 style="text-align:left"><strong>${strings.header}</strong></h6>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p style="font-size:90%">${strings.subHeader}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Report Title -->
|
||||
${
|
||||
strings.header &&
|
||||
`
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<h6 style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; color: inherit; word-wrap: normal; font-weight: normal; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 23px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; text-align: center;"><strong style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">${strings.header}</strong></h6>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
`
|
||||
}
|
||||
${
|
||||
strings.subHeader &&
|
||||
`
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 95%;">${strings.subHeader}</p>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
`
|
||||
}
|
||||
<!-- End Report Title -->
|
||||
<!-- Task Detail -->
|
||||
<table class="row">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small-12 large-12 columns first last">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>${strings.body}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- End Task Detail -->
|
||||
<!-- Footer -->
|
||||
<table class="row collapsed footer" id="non-printable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="small-3 large-3 columns first">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size:70%; padding-right:10px">${strings?.dateLine || now()}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
<th class="small-6 large-6 columns">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size:70%; text-align:center">${strings?.footer || defaultFooter()}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
<th class="small-3 large-3 columns last">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size:70%"> </p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>` +
|
||||
end
|
||||
${
|
||||
strings.body &&
|
||||
`
|
||||
<!-- Report Detail -->
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
${strings.body}
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
<!-- End Report Detail -->
|
||||
`
|
||||
}
|
||||
` +
|
||||
end(strings.dateLine)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
2713
server/email/html.js
2713
server/email/html.js
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,9 @@ const logEmail = async (req, email) => {
|
||||
to: req?.body?.to,
|
||||
cc: req?.body?.cc,
|
||||
subject: req?.body?.subject,
|
||||
email
|
||||
email,
|
||||
errorMessage: error?.message,
|
||||
errorStack: error?.stack
|
||||
// info,
|
||||
});
|
||||
}
|
||||
@@ -68,6 +70,7 @@ const sendServerEmail = async ({ subject, text }) => {
|
||||
]
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(err, info) => {
|
||||
logger.log("server-email-failure", err ? "error" : "debug", null, null, {
|
||||
message: err?.message,
|
||||
@@ -80,6 +83,108 @@ const sendServerEmail = async ({ subject, text }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendWelcomeEmail = async ({ to, resetLink, dateLine, features, bcc }) => {
|
||||
try {
|
||||
await mailer.sendMail({
|
||||
from: InstanceManager({
|
||||
imex: `ImEX Online <noreply@imex.online>`,
|
||||
rome: `Rome Online <noreply@romeonline.io>`
|
||||
}),
|
||||
to,
|
||||
bcc,
|
||||
subject: InstanceManager({
|
||||
imex: "Welcome to the ImEX Online platform.",
|
||||
rome: "Welcome to the Rome Online platform."
|
||||
}),
|
||||
html: generateEmailTemplate({
|
||||
header: InstanceManager({
|
||||
imex: "Welcome to the ImEX Online platform.",
|
||||
rome: "Welcome to the Rome Online platform."
|
||||
}),
|
||||
subHeader: `Your ${InstanceManager({imex: features?.allAccess ? "ImEX Online": "ImEX Lite", rome: features?.allAccess ? "RO Manager" : "RO Basic"})} shop setup has been completed, and this email will include all the information you need to begin.`,
|
||||
body: `
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To finish setting up your account, visit this link and enter your desired password. <a href=${resetLink} style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Reset Password</a></p>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To access your ${InstanceManager({imex: features.allAccess ? "ImEX Online": "ImEX Lite", rome: features.allAccess ? "RO Manager" : "RO Basic"})} shop, visit <a href=${InstanceManager({imex: "https://imex.online/", rome: "https://romeonline.io/"})} style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${InstanceManager({imex: "imex.online", rome: "romeonline.io"})}</a>. Your username is your email, and your password is what you previously set up. Contact support for additional logins.</p>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
${InstanceManager({
|
||||
rome: `
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To push estimates over from your estimating system, you must download the Web-Est EMS Unzipper & Rome Online Partner (Computers using Windows only). Here are some steps to help you get started.</p>
|
||||
</td><tr>
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
|
||||
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Download and set up the Web-Est EMS Unzipper - <a href="https://help.imex.online/en/article/how-to-set-up-the-ems-unzip-downloader-on-web-est-n9hbcv/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">How to setup the EMS Unzip Downloader on Web-Est</a></li>
|
||||
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Download and set up Rome Online Partner - <a href="https://help.imex.online/en/article/setting-up-the-rome-online-partner-1xsw8tb/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Setting up the Rome Online Partner</a></li>
|
||||
</ul>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Once you successfully set up the partner, now it's time to do some initial in-product items: Please note, <b>an estimate must be exported from the estimating platform to use tours.</b></p>
|
||||
</td><tr>
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
|
||||
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Send estimate from Web-Est to RO Basic - <a href="https://help.imex.online/en/article/how-to-send-estimates-from-web-est-to-the-management-system-ox0h9a/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">How to setup the EMS Unzip Downloader on Web-Est</a></li>
|
||||
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Once completed, learn how to use RO Basic by accessing the tours at the bottom middle of the screen (labeled “Training Tours”). These walkthroughs will show you how to navigate from creating an RO to closing an RO - <a href="https://www.youtube.com/watch?v=gcbSe5med0I" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">ROME Collision Management Youtube Training Videos</a></li>
|
||||
</ul>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">If you need any assistance with setting up the programs, or if you want a dedicated Q&A session with one of our customer success specialists, schedule by clicking this link - <a href="https://rometech.zohobookings.com/#/PSAT" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Rome Basic Training Booking</a></p>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">If you have additional questions or need any support, feel free to use the RO Basic support chat (blue chat box located in the bottom right corner) or give us a call at <a href="tel:14103576700" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">(410) 357-6700</a>. We are here to help make your experience seamless!</p>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
`
|
||||
})}
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">In addition to the training tour, you can also book a live one-on-one demo to see exactly how our system can help streamline the repair process at your shop, schedule by clicking this link - <a href="https://outlook.office.com/bookwithme/user/0aa3ae2c6d59497d9f93fb72479848dc@imexsystems.ca/meetingtype/Qy7CsXl5MkuUJ0NRD7B1AA2?anonymous&ep=mlink" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${InstanceManager({imex: "ImEX Lite", rome: "Rome Basic"})} Demo Booking</a></p>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 0px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Thanks,</p>
|
||||
</td></tr>
|
||||
</tbody></table></th>
|
||||
</tr></tbody></table>
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 0px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
|
||||
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">The ${InstanceManager({imex: "ImEX Online", rome: "Rome Online"})} Team</p>
|
||||
`,
|
||||
dateLine
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log("server-email-failure", "error", null, null, { error });
|
||||
}
|
||||
};
|
||||
|
||||
const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => {
|
||||
try {
|
||||
mailer.sendMail(
|
||||
@@ -93,6 +198,7 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
|
||||
...(type === "text" ? { text } : { html }),
|
||||
attachments: attachments || null
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(err, info) => {
|
||||
// (message, type, user, record, meta
|
||||
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
|
||||
@@ -143,22 +249,20 @@ const sendEmail = async (req, res) => {
|
||||
to: req.body.to,
|
||||
cc: req.body.cc,
|
||||
subject: req.body.subject,
|
||||
attachments:
|
||||
[
|
||||
...((req.body.attachments &&
|
||||
attachments: [
|
||||
...(req.body.attachments &&
|
||||
req.body.attachments.map((a) => {
|
||||
return {
|
||||
filename: a.filename,
|
||||
path: a.path
|
||||
};
|
||||
})) ||
|
||||
[]),
|
||||
})),
|
||||
...downloadedMedia.map((a) => {
|
||||
return {
|
||||
path: a
|
||||
};
|
||||
})
|
||||
] || null,
|
||||
],
|
||||
html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html,
|
||||
ses: {
|
||||
// optional extra arguments for SendRawEmail
|
||||
@@ -273,6 +377,7 @@ ${body.bounce?.bouncedRecipients.map(
|
||||
)}
|
||||
`
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(err, info) => {
|
||||
logger.log("sns-error", err ? "error" : "debug", "api", null, {
|
||||
errorMessage: err?.message,
|
||||
@@ -294,5 +399,6 @@ module.exports = {
|
||||
sendEmail,
|
||||
sendServerEmail,
|
||||
sendTaskEmail,
|
||||
emailBounce
|
||||
emailBounce,
|
||||
sendWelcomeEmail
|
||||
};
|
||||
|
||||
@@ -17,11 +17,13 @@ const { formatTaskPriority } = require("../notifications/stringHelpers");
|
||||
const tasksEmailQueue = taskEmailQueue();
|
||||
|
||||
// Cleanup function for the Tasks Email Queue
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const tasksEmailQueueCleanup = async () => {
|
||||
try {
|
||||
// Example async operation
|
||||
// console.log("Performing Tasks Email Reminder process cleanup...");
|
||||
await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve()));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (err) {
|
||||
// console.error("Tasks Email Reminder process cleanup failed:", err);
|
||||
}
|
||||
@@ -254,10 +256,15 @@ const tasksRemindEmail = async (req, res) => {
|
||||
header: `${allTasks.length} Tasks require your attention`,
|
||||
subHeader: `Please click on the Tasks below to view the Task.`,
|
||||
dateLine,
|
||||
body: `<ul>
|
||||
body: `
|
||||
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; margin: 1%; padding-left: 30px;">
|
||||
${allTasks
|
||||
.map((task) =>
|
||||
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
|
||||
`
|
||||
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">
|
||||
<a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a>
|
||||
</li>
|
||||
`.trim()
|
||||
)
|
||||
.join("")}
|
||||
</ul>`
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
const path = require("path");
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("../utils/logger");
|
||||
//const { sendProManagerWelcomeEmail } = require("../email/sendemail");
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON);
|
||||
//const generateEmailTemplate = require("../email/generateTemplate");
|
||||
const admin = require("firebase-admin");
|
||||
const moment = require("moment-timezone");
|
||||
const logger = require("../utils/logger");
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const { sendWelcomeEmail } = require("../email/sendemail");
|
||||
const { GET_USER_BY_EMAIL } = require("../graphql-client/queries");
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
@@ -201,6 +197,94 @@ const unsubscribe = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getWelcomeEmail = async (req, res) => {
|
||||
const { authid, email, bcc } = req.body;
|
||||
|
||||
try {
|
||||
// Fetch user from Firebase
|
||||
const userRecord = await admin.auth().getUser(authid);
|
||||
if (!userRecord) {
|
||||
throw { status: 404, message: "User not found in Firebase." };
|
||||
}
|
||||
|
||||
// Fetch user data from the database using GraphQL
|
||||
const dbUserResult = await client.request(GET_USER_BY_EMAIL, { email: email.toLowerCase() });
|
||||
|
||||
const dbUser = dbUserResult?.users?.[0];
|
||||
if (!dbUser) {
|
||||
throw { status: 404, message: "User not found in database." };
|
||||
}
|
||||
|
||||
// Validate email before proceeding
|
||||
if (!dbUser.validemail) {
|
||||
logger.log("admin-send-welcome-email-skip", "debug", req.user.email, null, {
|
||||
message: "User email is not valid, skipping email.",
|
||||
email
|
||||
});
|
||||
return res.status(200).json({ message: "User email is not valid, email not sent." });
|
||||
}
|
||||
|
||||
// Generate password reset link
|
||||
const resetLink = await admin.auth().generatePasswordResetLink(dbUser.email);
|
||||
|
||||
// Send welcome email
|
||||
await sendWelcomeEmail({
|
||||
to: dbUser.email,
|
||||
resetLink,
|
||||
dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"),
|
||||
features: dbUser.associations?.[0]?.bodyshop?.features,
|
||||
bcc
|
||||
});
|
||||
|
||||
// Log success and return response
|
||||
logger.log("admin-send-welcome-email", "debug", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true,
|
||||
emailSentTo: email
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Welcome email sent successfully." });
|
||||
} catch (error) {
|
||||
logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error });
|
||||
|
||||
if (!res.headersSent) {
|
||||
return res.status(error.status || 500).json({
|
||||
message: error.message || "Error sending welcome email.",
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getResetLink = async (req, res) => {
|
||||
const { authid, email } = req.body;
|
||||
logger.log("admin-reset-link", "debug", req.user.email, null, { authid, email });
|
||||
|
||||
try {
|
||||
// Fetch user from Firebase
|
||||
const userRecord = await admin.auth().getUser(authid);
|
||||
if (!userRecord) {
|
||||
throw { status: 404, message: "User not found in Firebase." };
|
||||
}
|
||||
|
||||
// Generate password reset link
|
||||
const resetLink = await admin.auth().generatePasswordResetLink(email);
|
||||
|
||||
// Log success and return response
|
||||
logger.log("admin-reset-link-success", "debug", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Reset link generated successfully.", resetLink });
|
||||
} catch (error) {
|
||||
return res.status(error.status || 500).json({
|
||||
message: error.message || "Error generating reset link.",
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
admin,
|
||||
createUser,
|
||||
@@ -208,23 +292,7 @@ module.exports = {
|
||||
getUser,
|
||||
sendNotification,
|
||||
subscribe,
|
||||
unsubscribe
|
||||
unsubscribe,
|
||||
getWelcomeEmail,
|
||||
getResetLink
|
||||
};
|
||||
|
||||
//Admin claims code.
|
||||
// const uid = "JEqqYlsadwPEXIiyRBR55fflfko1";
|
||||
|
||||
// admin
|
||||
// .auth()
|
||||
// .getUser(uid)
|
||||
// .then((user) => {
|
||||
// console.log(user);
|
||||
// admin.auth().setCustomUserClaims(uid, {
|
||||
// ioadmin: true,
|
||||
// "https://hasura.io/jwt/claims": {
|
||||
// "x-hasura-default-role": "debug",
|
||||
// "x-hasura-allowed-roles": ["admin"],
|
||||
// "x-hasura-user-id": uid,
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
const GraphQLClient = require("graphql-request").GraphQLClient;
|
||||
const path = require("path");
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
|
||||
//New bug introduced with Graphql Request.
|
||||
// https://github.com/prisma-labs/graphql-request/issues/206
|
||||
// const { Headers } = require("cross-fetch");
|
||||
// global.Headers = global.Headers || Headers;
|
||||
|
||||
exports.client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
|
||||
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
|
||||
headers: {
|
||||
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET
|
||||
}
|
||||
});
|
||||
|
||||
exports.unauthclient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT);
|
||||
const unauthorizedClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT);
|
||||
|
||||
module.exports = {
|
||||
client,
|
||||
unauthorizedClient
|
||||
};
|
||||
|
||||
@@ -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 = `
|
||||
mutation UPDATE_JOB($jobId: uuid!, $job: jobs_set_input!) {
|
||||
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{
|
||||
delete_dms_vehicles(where: {}) {
|
||||
affected_rows
|
||||
@@ -2853,3 +2884,47 @@ query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) {
|
||||
email
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.GET_USER_BY_EMAIL = `
|
||||
query GET_USER_BY_EMAIL($email: String!) {
|
||||
users(where: {email: {_eq: $email}}) {
|
||||
email
|
||||
validemail
|
||||
associations {
|
||||
id
|
||||
shopid
|
||||
bodyshop {
|
||||
id
|
||||
convenient_company
|
||||
features
|
||||
timezone
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
// Define the GraphQL query to get a job by RO number and shop ID
|
||||
exports.GET_JOB_BY_RO_NUMBER_AND_SHOP_ID = `
|
||||
query GET_JOB_BY_RO_NUMBER_AND_SHOP_ID($roNumber: String!, $shopId: uuid!) {
|
||||
jobs(where: {ro_number: {_eq: $roNumber}, shopid: {_eq: $shopId}}, limit: 1) {
|
||||
id
|
||||
shopid
|
||||
bodyshop {
|
||||
timezone
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Define the mutation to insert a new document
|
||||
exports.INSERT_NEW_DOCUMENT = `
|
||||
mutation INSERT_NEW_DOCUMENT($docInput: [documents_insert_input!]!) {
|
||||
insert_documents(objects: $docInput) {
|
||||
returning {
|
||||
id
|
||||
name
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
143
server/integrations/VSSTA/vsstaIntegrationRoute.js
Normal file
143
server/integrations/VSSTA/vsstaIntegrationRoute.js
Normal 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;
|
||||
@@ -107,17 +107,24 @@ const handleInvoiceBasedPayment = async (values, logger, logMeta, res) => {
|
||||
});
|
||||
|
||||
// Create payment response record
|
||||
const responseResults = await gqlClient.request(INSERT_PAYMENT_RESPONSE, {
|
||||
const responseResults = await gqlClient
|
||||
.request(INSERT_PAYMENT_RESPONSE, {
|
||||
paymentResponse: {
|
||||
amount: values.total,
|
||||
bodyshopid: bodyshop.id,
|
||||
paymentid: paymentResult.id,
|
||||
paymentid: paymentResult.insert_payments.returning[0].id,
|
||||
jobid: job.id,
|
||||
declinereason: "Approved",
|
||||
ext_paymentid: values.paymentid,
|
||||
successful: true,
|
||||
response: values
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.log("intellipay-postback-invoice-response-error", "ERROR", "api", null, {
|
||||
err,
|
||||
...logMeta
|
||||
});
|
||||
});
|
||||
|
||||
logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { sendTaskEmail } = require("../../email/sendemail");
|
||||
const generateEmailTemplate = require("../../email/generateTemplate");
|
||||
const { InstanceEndpoints } = require("../../utils/instanceMgr");
|
||||
|
||||
/**
|
||||
* @description Send notification email to the user
|
||||
@@ -22,11 +23,9 @@ const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, lo
|
||||
body: jobs.jobs
|
||||
.map(
|
||||
(job) =>
|
||||
`Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${
|
||||
job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()
|
||||
} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
|
||||
`<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}</p>`
|
||||
)
|
||||
.join("<br/>")
|
||||
.join("")
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -37,7 +37,9 @@ beforeEach(() => {
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "payment123"
|
||||
insert_payments: {
|
||||
returning: [{ id: "payment123" }]
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
insert_payment_response: {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
const path = require("path");
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
const logger = require("../utils/logger");
|
||||
const { Upload } = require("@aws-sdk/lib-storage");
|
||||
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||||
const { InstanceRegion } = require("../utils/instanceMgr");
|
||||
const archiver = require("archiver");
|
||||
const stream = require("node:stream");
|
||||
const base64UrlEncode = require("./util/base64UrlEncode");
|
||||
const createHmacSha256 = require("./util/createHmacSha256");
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
@@ -10,31 +14,32 @@ const {
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand
|
||||
} = require("@aws-sdk/client-s3");
|
||||
const { Upload } = require("@aws-sdk/lib-storage");
|
||||
|
||||
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||||
const crypto = require("crypto");
|
||||
const { InstanceRegion } = require("../utils/instanceMgr");
|
||||
const {
|
||||
GET_DOCUMENTS_BY_JOB,
|
||||
QUERY_TEMPORARY_DOCS,
|
||||
GET_DOCUMENTS_BY_IDS,
|
||||
DELETE_MEDIA_DOCUMENTS
|
||||
} = require("../graphql-client/queries");
|
||||
const archiver = require("archiver");
|
||||
const stream = require("node:stream");
|
||||
|
||||
const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` //Direct Lambda function access to bypass CDN.
|
||||
const imgproxyKey = process.env.IMGPROXY_KEY;
|
||||
const imgproxySalt = process.env.IMGPROXY_SALT;
|
||||
const imgproxyDestinationBucket = process.env.IMGPROXY_DESTINATION_BUCKET;
|
||||
|
||||
//Generate a signed upload link for the S3 bucket.
|
||||
//All uploads must be going to the same shop and jobid.
|
||||
exports.generateSignedUploadUrls = async (req, res) => {
|
||||
/**
|
||||
* Generate a Signed URL Link for the s3 bucket.
|
||||
* All Uploads must be going to the same Shop and JobId
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
const generateSignedUploadUrls = async (req, res) => {
|
||||
const { filenames, bodyshopid, jobid } = req.body;
|
||||
try {
|
||||
logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, { filenames, bodyshopid, jobid });
|
||||
logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, {
|
||||
filenames,
|
||||
bodyshopid,
|
||||
jobid
|
||||
});
|
||||
|
||||
const signedUrls = [];
|
||||
for (const filename of filenames) {
|
||||
@@ -50,24 +55,32 @@ exports.generateSignedUploadUrls = async (req, res) => {
|
||||
}
|
||||
|
||||
logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls });
|
||||
res.json({
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
signedUrls
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, {
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.getThumbnailUrls = async (req, res) => {
|
||||
/**
|
||||
* Get Thumbnail URLS
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
const getThumbnailUrls = async (req, res) => {
|
||||
const { jobid, billid } = req.body;
|
||||
|
||||
try {
|
||||
@@ -86,10 +99,11 @@ exports.getThumbnailUrls = async (req, res) => {
|
||||
|
||||
for (const document of data.documents) {
|
||||
//Format to follow:
|
||||
//<Cloudfront_to_lambda>/<hmac with SHA of entire request URI path (with base64 encoded URL if needed), beginning with unencoded/unhashed Salt>/<remainder of url - resize params >/< base 64 URL encoded to image path>
|
||||
|
||||
//<Cloudfront_to_lambda>/<hmac with SHA of entire request URI path (with base64 encoded URL if needed), beginning with un-encoded/un-hashed Salt>/<remainder of url - resize params >/< base 64 URL encoded to image path>
|
||||
//When working with documents from Cloudinary, the URL does not include the extension.
|
||||
|
||||
let key;
|
||||
|
||||
if (/\.[^/.]+$/.test(document.key)) {
|
||||
key = document.key;
|
||||
} else {
|
||||
@@ -98,12 +112,12 @@ exports.getThumbnailUrls = async (req, res) => {
|
||||
// Build the S3 path to the object.
|
||||
const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`;
|
||||
const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path);
|
||||
|
||||
//Thumbnail Generation Block
|
||||
const thumbProxyPath = `${thumbResizeParams}/${base64UrlEncodedKeyString}`;
|
||||
const thumbHmacSalt = createHmacSha256(`${imgproxySalt}/${thumbProxyPath}`);
|
||||
|
||||
//Full Size URL block
|
||||
|
||||
const fullSizeProxyPath = `${base64UrlEncodedKeyString}`;
|
||||
const fullSizeHmacSalt = createHmacSha256(`${imgproxySalt}/${fullSizeProxyPath}`);
|
||||
|
||||
@@ -114,8 +128,8 @@ exports.getThumbnailUrls = async (req, res) => {
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
Key: key
|
||||
});
|
||||
const presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 });
|
||||
s3Props.presignedGetUrl = presignedGetUrl;
|
||||
|
||||
s3Props.presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 });
|
||||
|
||||
const originalProxyPath = `raw:1/${base64UrlEncodedKeyString}`;
|
||||
const originalHmacSalt = createHmacSha256(`${imgproxySalt}/${originalProxyPath}`);
|
||||
@@ -133,7 +147,7 @@ exports.getThumbnailUrls = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
res.json(proxiedUrls);
|
||||
return res.json(proxiedUrls);
|
||||
//Iterate over them, build the link based on the media type, and return the array.
|
||||
} catch (error) {
|
||||
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, {
|
||||
@@ -142,57 +156,72 @@ exports.getThumbnailUrls = async (req, res) => {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
res.status(400).json({ message: error.message, stack: error.stack });
|
||||
|
||||
return res.status(400).json({ message: error.message, stack: error.stack });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getBillFiles = async (req, res) => {
|
||||
//Givena bill ID, get the documents associated to it.
|
||||
};
|
||||
|
||||
exports.downloadFiles = async (req, res) => {
|
||||
/**
|
||||
* Download Files
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
const downloadFiles = async (req, res) => {
|
||||
//Given a series of document IDs or keys, generate a file (or a link) to download all images in bulk
|
||||
const { jobid, billid, documentids } = req.body;
|
||||
const { jobId, billid, documentids } = req.body;
|
||||
|
||||
try {
|
||||
logger.log("imgproxy-download", "DEBUG", req.user?.email, jobid, { billid, jobid, documentids });
|
||||
logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids });
|
||||
|
||||
//Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components.
|
||||
const client = req.userGraphQLClient;
|
||||
|
||||
//Query for the keys of the document IDs
|
||||
const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids });
|
||||
//Using the Keys, get all of the S3 links, zip them, and send back to the client.
|
||||
|
||||
//Using the Keys, get all the S3 links, zip them, and send back to the client.
|
||||
const s3client = new S3Client({ region: InstanceRegion() });
|
||||
const archiveStream = archiver("zip");
|
||||
|
||||
archiveStream.on("error", (error) => {
|
||||
console.error("Archival encountered an error:", error);
|
||||
throw new Error(error);
|
||||
});
|
||||
const passthrough = new stream.PassThrough();
|
||||
|
||||
archiveStream.pipe(passthrough);
|
||||
const passThrough = new stream.PassThrough();
|
||||
|
||||
archiveStream.pipe(passThrough);
|
||||
|
||||
for (const key of data.documents.map((d) => d.key)) {
|
||||
const response = await s3client.send(new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key }));
|
||||
// :: `response.Body` is a Buffer
|
||||
console.log(path.basename(key));
|
||||
const response = await s3client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
Key: key
|
||||
})
|
||||
);
|
||||
|
||||
archiveStream.append(response.Body, { name: path.basename(key) });
|
||||
}
|
||||
|
||||
archiveStream.finalize();
|
||||
await archiveStream.finalize();
|
||||
|
||||
const archiveKey = `archives/${jobid}/archive-${new Date().toISOString()}.zip`;
|
||||
const archiveKey = `archives/${jobId || "na"}/archive-${new Date().toISOString()}.zip`;
|
||||
|
||||
const parallelUploads3 = new Upload({
|
||||
client: s3client,
|
||||
queueSize: 4, // optional concurrency configuration
|
||||
leavePartsOnError: false, // optional manually handle dropped parts
|
||||
params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passthrough }
|
||||
params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passThrough }
|
||||
});
|
||||
|
||||
parallelUploads3.on("httpUploadProgress", (progress) => {
|
||||
console.log(progress);
|
||||
});
|
||||
// Disabled progress logging for upload, uncomment if needed
|
||||
// parallelUploads3.on("httpUploadProgress", (progress) => {
|
||||
// console.log(progress);
|
||||
// });
|
||||
|
||||
await parallelUploads3.done();
|
||||
|
||||
const uploadResult = await parallelUploads3.done();
|
||||
//Generate the presigned URL to download it.
|
||||
const presignedUrl = await getSignedUrl(
|
||||
s3client,
|
||||
@@ -200,20 +229,27 @@ exports.downloadFiles = async (req, res) => {
|
||||
{ expiresIn: 360 }
|
||||
);
|
||||
|
||||
res.json({ success: true, url: presignedUrl });
|
||||
return res.json({ success: true, url: presignedUrl });
|
||||
//Iterate over them, build the link based on the media type, and return the array.
|
||||
} catch (error) {
|
||||
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, {
|
||||
jobid,
|
||||
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobId, {
|
||||
jobId,
|
||||
billid,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
res.status(400).json({ message: error.message, stack: error.stack });
|
||||
|
||||
return res.status(400).json({ message: error.message, stack: error.stack });
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteFiles = async (req, res) => {
|
||||
/**
|
||||
* Delete Files
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
const deleteFiles = async (req, res) => {
|
||||
//Mark a file for deletion in s3. Lifecycle deletion will actually delete the copy in the future.
|
||||
//Mark as deleted from the documents section of the database.
|
||||
const { ids } = req.body;
|
||||
@@ -232,7 +268,7 @@ exports.deleteFiles = async (req, res) => {
|
||||
(async () => {
|
||||
try {
|
||||
// Delete the original object
|
||||
const deleteResult = await s3client.send(
|
||||
await s3client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
Key: document.key
|
||||
@@ -250,23 +286,30 @@ exports.deleteFiles = async (req, res) => {
|
||||
const result = await Promise.all(deleteTransactions);
|
||||
const errors = result.filter((d) => d.error);
|
||||
|
||||
//Delete only the succesful deletes.
|
||||
//Delete only the successful deletes.
|
||||
const deleteMutationResult = await client.request(DELETE_MEDIA_DOCUMENTS, {
|
||||
ids: result.filter((t) => !t.error).map((d) => d.id)
|
||||
});
|
||||
|
||||
res.json({ errors, deleteMutationResult });
|
||||
return res.json({ errors, deleteMutationResult });
|
||||
} catch (error) {
|
||||
logger.log("imgproxy-delete-files-error", "ERROR", req.user.email, null, {
|
||||
ids,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
res.status(400).json({ message: error.message, stack: error.stack });
|
||||
|
||||
return res.status(400).json({ message: error.message, stack: error.stack });
|
||||
}
|
||||
};
|
||||
|
||||
exports.moveFiles = async (req, res) => {
|
||||
/**
|
||||
* Move Files
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
const moveFiles = async (req, res) => {
|
||||
const { documents, tojobid } = req.body;
|
||||
try {
|
||||
logger.log("imgproxy-move-files", "DEBUG", req.user.email, null, { documents, tojobid });
|
||||
@@ -278,7 +321,7 @@ exports.moveFiles = async (req, res) => {
|
||||
(async () => {
|
||||
try {
|
||||
// Copy the object to the new key
|
||||
const copyresult = await s3client.send(
|
||||
await s3client.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
CopySource: `${imgproxyDestinationBucket}/${document.from}`,
|
||||
@@ -288,7 +331,7 @@ exports.moveFiles = async (req, res) => {
|
||||
);
|
||||
|
||||
// Delete the original object
|
||||
const deleteResult = await s3client.send(
|
||||
await s3client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
Key: document.from
|
||||
@@ -297,7 +340,12 @@ exports.moveFiles = async (req, res) => {
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
return { id: document.id, from: document.from, error: error, bucket: imgproxyDestinationBucket };
|
||||
return {
|
||||
id: document.id,
|
||||
from: document.from,
|
||||
error: error,
|
||||
bucket: imgproxyDestinationBucket
|
||||
};
|
||||
}
|
||||
})()
|
||||
);
|
||||
@@ -307,6 +355,7 @@ exports.moveFiles = async (req, res) => {
|
||||
const errors = result.filter((d) => d.error);
|
||||
|
||||
let mutations = "";
|
||||
|
||||
result
|
||||
.filter((d) => !d.error)
|
||||
.forEach((d, idx) => {
|
||||
@@ -321,14 +370,16 @@ exports.moveFiles = async (req, res) => {
|
||||
});
|
||||
|
||||
const client = req.userGraphQLClient;
|
||||
|
||||
if (mutations !== "") {
|
||||
const mutationResult = await client.request(`mutation {
|
||||
${mutations}
|
||||
}`);
|
||||
res.json({ errors, mutationResult });
|
||||
} else {
|
||||
res.json({ errors: "No images were succesfully moved on remote server. " });
|
||||
|
||||
return res.json({ errors, mutationResult });
|
||||
}
|
||||
|
||||
return res.json({ errors: "No images were successfully moved on remote server. " });
|
||||
} catch (error) {
|
||||
logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, {
|
||||
documents,
|
||||
@@ -336,13 +387,15 @@ exports.moveFiles = async (req, res) => {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
res.status(400).json({ message: error.message, stack: error.stack });
|
||||
|
||||
return res.status(400).json({ message: error.message, stack: error.stack });
|
||||
}
|
||||
};
|
||||
|
||||
function base64UrlEncode(str) {
|
||||
return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function createHmacSha256(data) {
|
||||
return crypto.createHmac("sha256", imgproxyKey).update(data).digest("base64url");
|
||||
}
|
||||
module.exports = {
|
||||
generateSignedUploadUrls,
|
||||
getThumbnailUrls,
|
||||
downloadFiles,
|
||||
deleteFiles,
|
||||
moveFiles
|
||||
};
|
||||
|
||||
@@ -1,42 +1,55 @@
|
||||
const path = require("path");
|
||||
const _ = require("lodash");
|
||||
const logger = require("../utils/logger");
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const queries = require("../graphql-client/queries");
|
||||
const determineFileType = require("./util/determineFileType");
|
||||
const { DELETE_MEDIA_DOCUMENTS } = require("../graphql-client/queries");
|
||||
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
|
||||
var cloudinary = require("cloudinary").v2;
|
||||
const cloudinary = require("cloudinary").v2;
|
||||
cloudinary.config(process.env.CLOUDINARY_URL);
|
||||
|
||||
/**
|
||||
* @description Creates a signed upload URL for Cloudinary.
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
const createSignedUploadURL = (req, res) => {
|
||||
logger.log("media-signed-upload", "DEBUG", req.user.email, null, null);
|
||||
res.send(cloudinary.utils.api_sign_request(req.body, process.env.CLOUDINARY_API_SECRET));
|
||||
};
|
||||
|
||||
exports.createSignedUploadURL = createSignedUploadURL;
|
||||
|
||||
/**
|
||||
* @description Downloads files from Cloudinary.
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
const downloadFiles = (req, res) => {
|
||||
const { ids } = req.body;
|
||||
|
||||
logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null);
|
||||
|
||||
const url = cloudinary.utils.download_zip_url({
|
||||
public_ids: ids,
|
||||
flatten_folders: true
|
||||
});
|
||||
|
||||
res.send(url);
|
||||
};
|
||||
exports.downloadFiles = downloadFiles;
|
||||
|
||||
/**
|
||||
* @description Deletes files from Cloudinary and Apollo.
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteFiles = async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
const types = _.groupBy(ids, (x) => DetermineFileType(x.type));
|
||||
|
||||
const types = _.groupBy(ids, (x) => determineFileType(x.type));
|
||||
|
||||
logger.log("media-bulk-delete", "DEBUG", req.user.email, ids, null);
|
||||
|
||||
const returns = [];
|
||||
|
||||
if (types.image) {
|
||||
//delete images
|
||||
|
||||
@@ -47,8 +60,8 @@ const deleteFiles = async (req, res) => {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (types.video) {
|
||||
//delete images returns.push(
|
||||
returns.push(
|
||||
await cloudinary.api.delete_resources(
|
||||
types.video.map((x) => x.key),
|
||||
@@ -56,8 +69,8 @@ const deleteFiles = async (req, res) => {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (types.raw) {
|
||||
//delete images returns.push(
|
||||
returns.push(
|
||||
await cloudinary.api.delete_resources(
|
||||
types.raw.map((x) => `${x.key}.${x.extension}`),
|
||||
@@ -68,6 +81,7 @@ const deleteFiles = async (req, res) => {
|
||||
|
||||
// Delete it on apollo.
|
||||
const successfulDeletes = [];
|
||||
|
||||
returns.forEach((resType) => {
|
||||
Object.keys(resType.deleted).forEach((key) => {
|
||||
if (resType.deleted[key] === "deleted" || resType.deleted[key] === "not_found") {
|
||||
@@ -77,7 +91,7 @@ const deleteFiles = async (req, res) => {
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request(queries.DELETE_MEDIA_DOCUMENTS, {
|
||||
const result = await client.request(DELETE_MEDIA_DOCUMENTS, {
|
||||
ids: ids.filter((i) => successfulDeletes.includes(i.key)).map((i) => i.id)
|
||||
});
|
||||
|
||||
@@ -91,24 +105,29 @@ const deleteFiles = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteFiles = deleteFiles;
|
||||
|
||||
/**
|
||||
* @description Renames keys in Cloudinary and updates the database.
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const renameKeys = async (req, res) => {
|
||||
const { documents, tojobid } = req.body;
|
||||
|
||||
logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents);
|
||||
|
||||
const proms = [];
|
||||
|
||||
documents.forEach((d) => {
|
||||
proms.push(
|
||||
(async () => {
|
||||
try {
|
||||
const res = {
|
||||
return {
|
||||
id: d.id,
|
||||
...(await cloudinary.uploader.rename(d.from, d.to, {
|
||||
resource_type: DetermineFileType(d.type)
|
||||
resource_type: determineFileType(d.type)
|
||||
}))
|
||||
};
|
||||
return res;
|
||||
} catch (error) {
|
||||
return { id: d.id, from: d.from, error: error };
|
||||
}
|
||||
@@ -148,18 +167,13 @@ const renameKeys = async (req, res) => {
|
||||
}`);
|
||||
res.json({ errors, mutationResult });
|
||||
} else {
|
||||
res.json({ errors: "No images were succesfully moved on remote server. " });
|
||||
res.json({ errors: "No images were successfully moved on remote server. " });
|
||||
}
|
||||
};
|
||||
exports.renameKeys = renameKeys;
|
||||
|
||||
//Also needs to be updated in upload utility and mobile app.
|
||||
function DetermineFileType(filetype) {
|
||||
if (!filetype) return "auto";
|
||||
else if (filetype.startsWith("image")) return "image";
|
||||
else if (filetype.startsWith("video")) return "video";
|
||||
else if (filetype.startsWith("application/pdf")) return "image";
|
||||
else if (filetype.startsWith("application")) return "raw";
|
||||
|
||||
return "auto";
|
||||
}
|
||||
module.exports = {
|
||||
createSignedUploadURL,
|
||||
downloadFiles,
|
||||
deleteFiles,
|
||||
renameKeys
|
||||
};
|
||||
|
||||
98
server/media/tests/media-utils.test.js
Normal file
98
server/media/tests/media-utils.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
9
server/media/util/base64UrlEncode.js
Normal file
9
server/media/util/base64UrlEncode.js
Normal 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;
|
||||
12
server/media/util/createHmacSha256.js
Normal file
12
server/media/util/createHmacSha256.js
Normal 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;
|
||||
17
server/media/util/determineFileType.js
Normal file
17
server/media/util/determineFileType.js
Normal 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;
|
||||
17
server/middleware/vsstaIntegrationMiddleware.js
Normal file
17
server/middleware/vsstaIntegrationMiddleware.js
Normal 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;
|
||||
@@ -133,11 +133,19 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
|
||||
subHeader: `Dear ${firstName},`,
|
||||
dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"),
|
||||
body: `
|
||||
<p>There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p><br/>
|
||||
<ul>
|
||||
${messages.map((msg) => `<li>${msg}</li>`).join("")}
|
||||
</ul><br/><br/>
|
||||
<p><a href="${InstanceEndpoints()}/manage/jobs/${jobId}">Please check the job for more details.</a></p>
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 100%;">There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p>
|
||||
</td></tr></table></th>
|
||||
</tr></tbody></table>
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
|
||||
${messages.map((msg) => `<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">${msg}</li>`).join("")}
|
||||
</ul>
|
||||
</td></tr></table></th>
|
||||
</tr><tbody></table>
|
||||
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
|
||||
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
|
||||
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;"><a href="${InstanceEndpoints()}/manage/jobs/${jobId}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Please check the job for more details.</a></p>
|
||||
`
|
||||
});
|
||||
await sendTaskEmail({
|
||||
@@ -226,6 +234,7 @@ const getQueue = () => {
|
||||
* @param {Object} options.logger - Logger instance for logging dispatch events.
|
||||
* @returns {Promise<void>} Resolves when all notifications are added to the queue.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => {
|
||||
const emailAddQueue = getQueue();
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ const newMediaAddedReassignedBuilder = (data) => {
|
||||
: data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new
|
||||
? "moved to this job"
|
||||
: "updated";
|
||||
const body = `An ${mediaType} has been ${action}.`;
|
||||
const body = `A ${mediaType} has been ${action}.`;
|
||||
|
||||
return buildNotification(data, "notifications.job.newMediaAdded", body, {
|
||||
mediaType,
|
||||
|
||||
@@ -63,7 +63,9 @@ const scenarioParser = async (req, jobIdField) => {
|
||||
}
|
||||
|
||||
if (!jobId) {
|
||||
if (process?.env?.NODE_ENV === "development") {
|
||||
logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,7 +90,9 @@ const scenarioParser = async (req, jobIdField) => {
|
||||
|
||||
// Exit early if no job watchers are found for this job
|
||||
if (isEmpty(jobWatchers)) {
|
||||
if (process?.env?.NODE_ENV === "development") {
|
||||
logger.log(`No watchers found for jobId "${jobId}", skipping notification parsing`, "info", "notifications");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,11 +134,13 @@ const scenarioParser = async (req, jobIdField) => {
|
||||
|
||||
// Exit early if no matching scenarios are identified
|
||||
if (isEmpty(matchingScenarios)) {
|
||||
if (process?.env?.NODE_ENV === "development") {
|
||||
logger.log(
|
||||
`No matching scenarios found for jobId "${jobId}", skipping notification dispatch`,
|
||||
"info",
|
||||
"notifications"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,11 +163,13 @@ const scenarioParser = async (req, jobIdField) => {
|
||||
|
||||
// Exit early if no notification associations are found
|
||||
if (isEmpty(associationsData?.associations)) {
|
||||
if (process?.env?.NODE_ENV === "development") {
|
||||
logger.log(
|
||||
`No notification associations found for jobId "${jobId}", skipping notification dispatch`,
|
||||
"info",
|
||||
"notifications"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,11 +204,13 @@ const scenarioParser = async (req, jobIdField) => {
|
||||
|
||||
// Exit early if no scenarios have eligible watchers after filtering
|
||||
if (isEmpty(finalScenarioData?.matchingScenarios)) {
|
||||
if (process?.env?.NODE_ENV === "development") {
|
||||
logger.log(
|
||||
`No eligible watchers after filtering for jobId "${jobId}", skipping notification dispatch`,
|
||||
"info",
|
||||
"notifications"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -259,7 +269,9 @@ const scenarioParser = async (req, jobIdField) => {
|
||||
}
|
||||
|
||||
if (isEmpty(scenariosToDispatch)) {
|
||||
if (process?.env?.NODE_ENV === "development") {
|
||||
logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require("express");
|
||||
const router = express.Router();
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops");
|
||||
const { updateUser, getUser, createUser } = require("../firebase/firebase-handler");
|
||||
const { updateUser, getUser, createUser, getWelcomeEmail, getResetLink } = require("../firebase/firebase-handler");
|
||||
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
|
||||
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
@@ -15,5 +15,7 @@ router.post("/updatecounter", updateCounter);
|
||||
router.post("/updateuser", updateUser);
|
||||
router.post("/getuser", getUser);
|
||||
router.post("/createuser", createUser);
|
||||
router.post("/sendwelcome", getWelcomeEmail);
|
||||
router.post("/resetlink", getResetLink);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { autohouse, claimscorp, chatter, kaizen, usageReport } = require("../data/data");
|
||||
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium } = require("../data/data");
|
||||
|
||||
router.post("/ah", autohouse);
|
||||
router.post("/cc", claimscorp);
|
||||
router.post("/chatter", chatter);
|
||||
router.post("/kaizen", kaizen);
|
||||
router.post("/usagereport", usageReport);
|
||||
router.post("/podium", podium);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
8
server/routes/intergrationRoutes.js
Normal file
8
server/routes/intergrationRoutes.js
Normal 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;
|
||||
@@ -1,6 +1,5 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const job = require("../job/job");
|
||||
const ppc = require("../ccc/partspricechange");
|
||||
const { partsScan } = require("../parts-scan/parts-scan");
|
||||
const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware");
|
||||
|
||||
Reference in New Issue
Block a user