Compare commits

..

4 Commits

Author SHA1 Message Date
Allan Carr
dfe0afd4f3 IO-3464 Document Edit
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-18 11:22:28 -08:00
Allan Carr
c675a328a8 IO-3464 Remove extra edit route
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-15 12:40:43 -08:00
Allan Carr
6eb432b5b7 IO-3464 Local Media Edit Image
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-12 20:56:39 -08:00
Allan Carr
56d50b855b IO-3464 S3 Document Editor
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-12-12 19:39:02 -08:00
25 changed files with 573 additions and 470 deletions

View File

@@ -0,0 +1,166 @@
import axios from "axios";
import { Result } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-local-upload/documents-local-upload.utility";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
const imgRef = useRef(null);
const [loading, setLoading] = useState(false);
const [uploaded, setuploaded] = useState(false);
const [loadedImageUrl, setLoadedImageUrl] = useState(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
const { t } = useTranslation();
const notification = useNotification();
const [uploading, setUploading] = useState(false);
const triggerUpload = useCallback(
async (dataUrl) => {
if (uploading) return;
setUploading(true);
const blob = await b64toBlob(dataUrl);
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
const parts = nameWithoutExt.split("-");
const baseParts = [];
for (let i = 0; i < parts.length; i++) {
if (/^\d+$/.test(parts[i])) {
break;
}
baseParts.push(parts[i]);
}
const adjustedBase = baseParts.length > 0 ? baseParts.join("-") : "edited";
const adjustedFilename = `${adjustedBase}.jpg`;
const file = new File([blob], adjustedFilename, { type: "image/jpeg" });
handleUpload({
ev: {
file: file,
filename: adjustedFilename,
onSuccess: () => {
setUploading(false);
setLoading(false);
setuploaded(true);
},
onError: () => {
setUploading(false);
setLoading(false);
}
},
context: {
jobid: jobid,
callback: () => {} // Optional callback
},
notification
});
},
[filename, jobid, notification, uploading]
);
useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
markerArea.current = new markerjs2.MarkerArea(imgRef.current);
// attach an event handler to assign annotated image back to our image element
markerArea.current.addEventListener("close", () => {
// NO OP
});
markerArea.current.addEventListener("render", (event) => {
const dataUrl = event.dataUrl;
imgRef.current.src = dataUrl;
markerArea.current.close();
triggerUpload(dataUrl);
});
// launch marker.js
markerArea.current.renderAtNaturalSize = true;
markerArea.current.renderImageType = "image/jpeg";
markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline";
markerArea.current.show();
}
}, [triggerUpload, imageLoaded]);
useEffect(() => {
if (!imageUrl) return;
const controller = new AbortController();
const loadImage = async () => {
setImageLoaded(false);
setImageLoading(true);
try {
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
const blobUrl = URL.createObjectURL(response.data);
setLoadedImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
});
} catch (error) {
if (axios.isCancel?.(error) || error.name === "CanceledError") {
// request was aborted — safe to ignore
return;
}
console.error("Failed to fetch image blob", error);
} finally {
if (!controller.signal.aborted) {
setImageLoading(false);
}
}
};
loadImage();
return () => {
controller.abort();
};
}, [imageUrl]);
useEffect(() => {
return () => {
if (loadedImageUrl) {
URL.revokeObjectURL(loadedImageUrl);
}
};
}, [loadedImageUrl]);
async function b64toBlob(url) {
const res = await fetch(url);
return await res.blob();
}
return (
<div>
{!loading && !uploaded && loadedImageUrl && (
<img
ref={imgRef}
src={loadedImageUrl}
alt="sample"
onLoad={() => setImageLoaded(true)}
onError={(error) => {
console.error("Failed to load image", error);
}}
style={{ maxWidth: "90vw", maxHeight: "90vh" }}
/>
)}
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(DocumentEditorLocalComponent);

View File

@@ -1,4 +1,5 @@
//import "tui-image-editor/dist/tui-image-editor.css";
import axios from "axios";
import { Result } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -6,8 +7,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import { GenerateSrcUrl } from "../jobs-documents-gallery/job-documents.utility";
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
@@ -23,6 +23,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
const imgRef = useRef(null);
const [loading, setLoading] = useState(false);
const [uploaded, setuploaded] = useState(false);
const [imageUrl, setImageUrl] = useState(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
const { t } = useTranslation();
const notification = useNotification();
@@ -55,7 +58,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
);
useEffect(() => {
if (imgRef.current !== null) {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
markerArea.current = new markerjs2.MarkerArea(imgRef.current);
@@ -78,7 +81,52 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
//markerArea.current.settings.displayMode = "inline";
markerArea.current.show();
}
}, [triggerUpload]);
}, [triggerUpload, imageLoaded]);
useEffect(() => {
if (!document?.id) return;
const controller = new AbortController();
const loadImage = async () => {
setImageLoaded(false);
setImageLoading(true);
try {
const response = await axios.post(
"/media/imgproxy/original",
{ documentId: document.id },
{
responseType: "blob",
signal: controller.signal
}
);
const blobUrl = URL.createObjectURL(response.data);
setImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
});
} catch (error) {
if (axios.isCancel?.(error) || error.name === "CanceledError") {
// request was aborted — safe to ignore
return;
}
console.error("Failed to fetch original image blob", error);
} finally {
setImageLoading(false);
}
};
loadImage();
return () => {
controller.abort();
};
}, [document]);
useEffect(() => {
return () => {
if (imageUrl) {
URL.revokeObjectURL(imageUrl);
}
};
}, [imageUrl]);
async function b64toBlob(url) {
const res = await fetch(url);
@@ -87,16 +135,21 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
return (
<div>
{!loading && !uploaded && (
{!loading && !uploaded && imageUrl && (
<img
ref={imgRef}
src={GenerateSrcUrl(document)}
src={imageUrl}
alt="sample"
crossOrigin="anonymous"
onLoad={() => setImageLoaded(true)}
onError={(error) => {
console.error("Failed to load original image", error);
}}
style={{ maxWidth: "90vw", maxHeight: "90vh" }}
/>
)}
{loading && <LoadingSpinner message={t("documents.labels.uploading")} />}
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
</div>
);

View File

@@ -11,6 +11,7 @@ import { setBodyshop } from "../../redux/user/user.actions";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import DocumentEditor from "./document-editor.component";
import { DocumentEditorLocalComponent } from "./document-editor-local.component";
const mapDispatchToProps = (dispatch) => ({
setBodyshop: (bs) => dispatch(setBodyshop(bs))
@@ -21,7 +22,7 @@ export default connect(null, mapDispatchToProps)(DocumentEditorContainer);
export function DocumentEditorContainer({ setBodyshop }) {
//Get the image details for the image to be saved.
//Get the document id from the search string.
const { documentId } = queryString.parse(useLocation().search);
const { documentId, imageUrl, filename, jobid } = queryString.parse(useLocation().search);
const { t } = useTranslation();
const {
loading: loadingShop,
@@ -32,24 +33,45 @@ export function DocumentEditorContainer({ setBodyshop }) {
nextFetchPolicy: "network-only"
});
useEffect(() => {
if (dataShop) setBodyshop(dataShop.bodyshops[0]);
}, [dataShop, setBodyshop]);
const isLocalMedia = !!dataShop?.bodyshops?.[0]?.uselocalmediaserver;
const { loading, error, data } = useQuery(GET_DOCUMENT_BY_PK, {
const {
loading: loadingDoc,
error: errorDoc,
data: dataDoc
} = useQuery(GET_DOCUMENT_BY_PK, {
variables: { documentId },
skip: !documentId,
skip: !documentId || isLocalMedia,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
if (loading || loadingShop) return <LoadingSpinner />;
if (error || errorShop) return <AlertComponent message={error.message || errorShop.message} type="error" />;
useEffect(() => {
if (dataShop) setBodyshop(dataShop.bodyshops[0]);
}, [dataShop, setBodyshop]);
if (!data || !data.documents_by_pk) return <Result status="404" title={t("general.errors.notfound")} />;
if (loadingShop) return <LoadingSpinner />;
if (errorShop) return <AlertComponent message={errorShop.message} type="error" />;
if (isLocalMedia) {
if (imageUrl && filename && jobid) {
return (
<div>
<DocumentEditorLocalComponent imageUrl={imageUrl} filename={filename} jobid={jobid} />
</div>
);
} else {
return <Result status="404" title={t("general.errors.notfound")} />;
}
}
if (loadingDoc) return <LoadingSpinner />;
if (errorDoc) return <AlertComponent message={errorDoc.message} type="error" />;
if (!dataDoc || !dataDoc.documents_by_pk) return <Result status="404" title={t("general.errors.notfound")} />;
return (
<div>
<DocumentEditor document={data ? data.documents_by_pk : null} />
<DocumentEditor document={dataDoc ? dataDoc.documents_by_pk : null} />
</div>
);
}

View File

@@ -9,10 +9,10 @@ import {
WarningFilled
} from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-layout";
import { gql, useMutation } from "@apollo/client";
import { Button, Dropdown, Input, Modal, Select, Space, Table, Tag, Typography } from "antd";
import { useMutation } from "@apollo/client";
import { Button, Dropdown, Input, Space, Table, Tag } from "antd";
import axios from "axios";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -47,19 +47,6 @@ import JobLinesExpander from "./job-lines-expander.component";
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const UPDATE_JOB_LINES_LOCATION_BULK = gql`
mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) {
update_joblines(where: { id: { _in: $ids } }, _set: { location: $location }) {
affected_rows
returning {
id
location
}
}
}
`;
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -96,9 +83,6 @@ export function JobLinesComponent({
isPartsEntry
}) {
const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK);
const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK);
const notification = useNotification();
const {
treatments: { Enhanced_Payroll }
} = useSplitTreatments({
@@ -119,83 +103,9 @@ export function JobLinesComponent({
}
});
// Bulk location modal state
const [bulkLocationOpen, setBulkLocationOpen] = useState(false);
const [bulkLocation, setBulkLocation] = useState(null);
const [bulkLocationSaving, setBulkLocationSaving] = useState(false);
const { t } = useTranslation();
const jobIsPrivate = bodyshop.md_ins_cos.find((c) => c.name === job.ins_co_nm)?.private;
const selectedLineIds = useMemo(() => selectedLines.map((l) => l?.id).filter(Boolean), [selectedLines]);
const commonSelectedLocation = useMemo(() => {
const locs = selectedLines
.map((l) => (typeof l?.location === "string" ? l.location : ""))
.map((x) => x.trim())
.filter(Boolean);
if (locs.length === 0) return null;
const uniq = _.uniq(locs);
return uniq.length === 1 ? uniq[0] : null;
}, [selectedLines]);
const openBulkLocationModal = () => {
setBulkLocation(commonSelectedLocation);
setBulkLocationOpen(true);
logImEXEvent("joblines_bulk_location_open", { count: selectedLineIds.length });
};
const closeBulkLocationModal = () => {
setBulkLocationOpen(false);
setBulkLocation(null);
};
const saveBulkLocation = async () => {
if (selectedLineIds.length === 0) return;
setBulkLocationSaving(true);
try {
const locationToSave = (bulkLocation ?? "").toString();
const result = await bulkUpdateLocations({
variables: {
ids: selectedLineIds,
location: locationToSave
}
});
if (!result?.errors) {
// Keep UI selection consistent without waiting for refetch
setSelectedLines((prev) =>
prev.map((l) => (l && selectedLineIds.includes(l.id) ? { ...l, location: locationToSave } : l))
);
notification["success"]({ message: t("joblines.successes.saved") });
logImEXEvent("joblines_bulk_location_saved", {
count: selectedLineIds.length,
location: locationToSave
});
closeBulkLocationModal();
if (refetch) refetch();
} else {
notification["error"]({
message: t("joblines.errors.saving", { error: JSON.stringify(result.errors) })
});
}
} catch (error) {
notification["error"]({
message: t("joblines.errors.saving", { error: error?.message || String(error) })
});
} finally {
setBulkLocationSaving(false);
}
};
const columns = [
{
title: "#",
@@ -261,16 +171,46 @@ export function JobLinesComponent({
? ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG", "PAO"]
: ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"]
},
{ text: t("joblines.fields.part_types.PAN"), value: ["PAN"] },
{ text: t("joblines.fields.part_types.PAP"), value: ["PAP"] },
{ text: t("joblines.fields.part_types.PAL"), value: ["PAL"] },
{ text: t("joblines.fields.part_types.PAA"), value: ["PAA"] },
{ text: t("joblines.fields.part_types.PAG"), value: ["PAG"] },
{ text: t("joblines.fields.part_types.PAS"), value: ["PAS"] },
{ text: t("joblines.fields.part_types.PASL"), value: ["PASL"] },
{ text: t("joblines.fields.part_types.PAC"), value: ["PAC"] },
{ text: t("joblines.fields.part_types.PAR"), value: ["PAR"] },
{ text: t("joblines.fields.part_types.PAM"), value: ["PAM"] },
{
text: t("joblines.fields.part_types.PAN"),
value: ["PAN"]
},
{
text: t("joblines.fields.part_types.PAP"),
value: ["PAP"]
},
{
text: t("joblines.fields.part_types.PAL"),
value: ["PAL"]
},
{
text: t("joblines.fields.part_types.PAA"),
value: ["PAA"]
},
{
text: t("joblines.fields.part_types.PAG"),
value: ["PAG"]
},
{
text: t("joblines.fields.part_types.PAS"),
value: ["PAS"]
},
{
text: t("joblines.fields.part_types.PASL"),
value: ["PASL"]
},
{
text: t("joblines.fields.part_types.PAC"),
value: ["PAC"]
},
{
text: t("joblines.fields.part_types.PAR"),
value: ["PAR"]
},
{
text: t("joblines.fields.part_types.PAM"),
value: ["PAM"]
},
...(isPartsEntry
? [
{
@@ -280,6 +220,7 @@ export function JobLinesComponent({
]
: [])
],
onFilter: (value, record) => value.includes(record.part_type),
render: (text, record) => (record.part_type ? t(`joblines.fields.part_types.${record.part_type}`) : null)
},
@@ -305,6 +246,7 @@ export function JobLinesComponent({
title: t("joblines.fields.mod_lbr_ty"),
dataIndex: "mod_lbr_ty",
key: "mod_lbr_ty",
sorter: (a, b) => alphaSort(a.mod_lbr_ty, b.mod_lbr_ty),
sortOrder: state.sortedInfo.columnKey === "mod_lbr_ty" && state.sortedInfo.order,
render: (text, record) => (record.mod_lbr_ty ? t(`joblines.fields.lbr_types.${record.mod_lbr_ty}`) : null)
@@ -313,6 +255,7 @@ export function JobLinesComponent({
title: t("joblines.fields.mod_lb_hrs"),
dataIndex: "mod_lb_hrs",
key: "mod_lb_hrs",
sorter: (a, b) => a.mod_lb_hrs - b.mod_lb_hrs,
sortOrder: state.sortedInfo.columnKey === "mod_lb_hrs" && state.sortedInfo.order
},
@@ -367,12 +310,18 @@ export function JobLinesComponent({
sorter: (a, b) => alphaSort(a.status, b.status),
sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
filteredValue: state.filteredInfo.status || null,
filters:
(jobLines &&
jobLines
.map((l) => l.status)
.filter(onlyUnique)
.map((s) => ({ text: s || t("dashboard.errors.status"), value: [s] }))) ||
.map((s) => {
return {
text: s || t("dashboard.errors.status"),
value: [s]
};
})) ||
[],
onFilter: (value, record) => value.includes(record.status),
render: (text, record) => <JobLineStatusPopup jobline={record} disabled={jobRO} />
@@ -427,7 +376,9 @@ export function JobLinesComponent({
});
}
});
await axios.post("/job/totalsssu", { id: job.id });
await axios.post("/job/totalsssu", {
id: job.id
});
if (refetch) refetch();
}}
>
@@ -497,36 +448,6 @@ export function JobLinesComponent({
return (
<div>
<PartsOrderModalContainer />
<Modal
open={bulkLocationOpen}
title={t("joblines.actions.updatelocation")}
onCancel={closeBulkLocationModal}
onOk={saveBulkLocation}
okButtonProps={{
disabled: jobRO || technician || selectedLineIds.length === 0,
loading: bulkLocationSaving
}}
destroyOnHidden
>
<Space direction="vertical" style={{ width: "100%" }}>
<Typography.Text type="secondary">
{t("general.labels.selected")}: {selectedLineIds.length}
</Typography.Text>
<Select
allowClear
placeholder={t("joblines.fields.location")}
value={bulkLocation}
style={{ width: "100%" }}
popupMatchSelectWidth={false}
onChange={(val) => setBulkLocation(val ?? null)}
options={(bodyshop?.md_parts_locations || []).map((loc) => ({ label: loc, value: loc }))}
/>
<Typography.Text type="secondary">{t("joblines.labels.bulk_location_help")}</Typography.Text>
</Space>
</Modal>
{!technician && (
<PartsOrderDrawer
job={job}
@@ -536,7 +457,6 @@ export function JobLinesComponent({
setTaskUpsertContext={setTaskUpsertContext}
/>
)}
<PageHeader
title={t("jobs.labels.estimatelines")}
extra={
@@ -545,16 +465,6 @@ export function JobLinesComponent({
<SyncOutlined />
</Button>
{/* Bulk Update Location */}
<Button
id="job-lines-bulk-update-location-button"
disabled={jobRO || technician || selectedLineIds.length === 0}
onClick={openBulkLocationModal}
>
{t("joblines.actions.updatelocation")}
{selectedLineIds.length > 0 && ` (${selectedLineIds.length})`}
</Button>
{job.special_coverage_policy && (
<Tag color="tomato">
<Space>
@@ -563,7 +473,6 @@ export function JobLinesComponent({
</Space>
</Tag>
)}
{!isPartsEntry && (
<JobLineDispatchButton
selectedLines={selectedLines}
@@ -572,11 +481,9 @@ export function JobLinesComponent({
disabled={technician}
/>
)}
{Enhanced_Payroll.treatment === "on" && (
<JobLineBulkAssignComponent selectedLines={selectedLines} setSelectedLines={setSelectedLines} job={job} />
)}
{!isPartsEntry && (
<Button
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
@@ -592,20 +499,27 @@ export function JobLinesComponent({
isinhouse: true,
date: dayjs(),
total: 0,
billlines: selectedLines.map((p) => ({
joblineid: p.id,
actual_price: p.act_price,
actual_cost: 0,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: { local: false, state: false, federal: false }
}))
billlines: selectedLines.map((p) => {
return {
joblineid: p.id,
actual_price: p.act_price,
actual_cost: 0, //p.act_price,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: {
local: false,
state: false,
federal: false
}
};
})
}
}
});
//Clear out the selected lines. IO-785
setSelectedLines([]);
}}
>
@@ -614,7 +528,6 @@ export function JobLinesComponent({
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
)}
<Button
id="job-lines-order-parts-button"
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
@@ -631,13 +544,13 @@ export function JobLinesComponent({
}
});
//Clear out the selected lines. IO-785
setSelectedLines([]);
}}
>
{t("parts.actions.order")}
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
{!isPartsEntry && (
<Button
id="job-lines-filter-parts-only-button"
@@ -654,11 +567,9 @@ export function JobLinesComponent({
<FilterFilled /> {t("jobs.actions.filterpartsonly")}
</Button>
)}
<Dropdown menu={markMenu} trigger={["click"]}>
<Button id="repair-data-mark-button">{t("jobs.actions.mark")}</Button>
</Dropdown>
{!isPartsEntry && (
<Button
disabled={jobRO || technician}
@@ -672,12 +583,9 @@ export function JobLinesComponent({
{t("joblines.actions.new")}
</Button>
)}
{!isPartsEntry &&
InstanceRenderManager({ rome: <JobSendPartPriceChangeComponent job={job} disabled={technician} /> })}
<JobCreateIOU job={job} selectedJobLines={selectedLines} />
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {
@@ -688,7 +596,6 @@ export function JobLinesComponent({
</Space>
}
/>
<Table
columns={columns}
rowKey="id"
@@ -696,7 +603,9 @@ export function JobLinesComponent({
pagination={false}
dataSource={jobLines}
onChange={handleTableChange}
scroll={{ x: true }}
scroll={{
x: true
}}
expandable={{
expandedRowRender: (record) =>
isPartsEntry ? (
@@ -705,6 +614,7 @@ export function JobLinesComponent({
<JobLinesExpander jobline={record} jobid={job.id} />
),
rowExpandable: () => true,
//expandRowByClick: true,
expandIcon: ({ expanded, onExpand, record }) =>
expanded ? (
<MinusCircleTwoTone onClick={(e) => onExpand(record, e)} />
@@ -717,15 +627,17 @@ export function JobLinesComponent({
/>
)
}}
onRow={(record) => ({
onDoubleClick: () => {
logImEXEvent("joblines_double_click_select", {});
const notMatchingLines = selectedLines.filter((i) => i.id !== record.id);
notMatchingLines.length !== selectedLines.length
? setSelectedLines(notMatchingLines)
: setSelectedLines([...selectedLines, record]);
}
})}
onRow={(record) => {
return {
onDoubleClick: () => {
logImEXEvent("joblines_double_click_select", {});
const notMatchingLines = selectedLines.filter((i) => i.id !== record.id);
notMatchingLines.length !== selectedLines.length
? setSelectedLines(notMatchingLines)
: setSelectedLines([...selectedLines, record]);
} // double click row
};
}}
rowSelection={{
selectedRowKeys: selectedLines.map((item) => item && item.id),
onSelectAll: (selected, selectedRows) => {

View File

@@ -1,23 +1,25 @@
import { useMutation } from "@apollo/client";
import { Select, Space, Tag } from "antd";
import { useEffect, useMemo, useState } from "react";
import { Select, Space } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
const CLEAR_VALUE = "__CLEAR_LOCATION__";
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function JobLineLocationPopup({ bodyshop, jobline, disabled }) {
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
const [location, setLocation] = useState(jobline.location);
const [updateJob] = useMutation(UPDATE_JOB_LINE);
const { t } = useTranslation();
@@ -27,78 +29,55 @@ export function JobLineLocationPopup({ bodyshop, jobline, disabled }) {
if (editing) setLocation(jobline.location);
}, [editing, jobline.location]);
const options = useMemo(() => {
const locs = bodyshop?.md_parts_locations || [];
return [
{ label: t("general.labels.none", "No location"), value: CLEAR_VALUE },
...locs.map((loc) => ({ label: loc, value: loc }))
];
}, [bodyshop?.md_parts_locations, t]);
const handleChange = (e) => {
setLocation(e);
};
const saveLocation = async (nextLocation) => {
setSaving(true);
const handleSave = async () => {
setLoading(true);
const result = await updateJob({
variables: { lineId: jobline.id, line: { location: location || "" } }
});
try {
const result = await updateJob({
variables: { lineId: jobline.id, line: { location: nextLocation || "" } }
});
if (!result.errors) {
notification["success"]({ message: t("joblines.successes.saved") });
} else {
notification["error"]({
message: t("joblines.errors.saving", {
error: JSON.stringify(result.errors)
})
});
}
} catch (error) {
if (!result.errors) {
notification["success"]({ message: t("joblines.successes.saved") });
} else {
notification["error"]({
message: t("joblines.errors.saving", { error: error?.message || String(error) })
message: t("joblines.errors.saving", {
error: JSON.stringify(result.errors)
})
});
} finally {
setSaving(false);
setEditing(false);
}
setLoading(false);
setEditing(false);
};
const handleChange = async (value) => {
const next = value === CLEAR_VALUE ? null : value;
setLocation(next);
await saveLocation(next);
};
if (editing) {
if (editing)
return (
<div style={{ width: "100%", display: "flex", alignItems: "center" }}>
<Select
autoFocus
size="small"
value={location ?? undefined}
loading={saving}
disabled={saving}
style={{ flex: 1, minWidth: 0 }}
popupMatchSelectWidth={false}
getPopupContainer={(triggerNode) => triggerNode.parentNode}
onChange={handleChange}
onBlur={() => !saving && setEditing(false)}
options={options}
/>
<div>
<LoadingSpinner loading={loading}>
<Select
autoFocus
allowClear
popupMatchSelectWidth={100}
value={location}
onClear={() => setLocation(null)}
onSelect={handleChange}
onBlur={handleSave}
>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
</LoadingSpinner>
</div>
);
}
return (
<div
style={{ width: "100%", minHeight: "2rem", cursor: disabled ? "default" : "pointer" }}
onClick={() => !disabled && setEditing(true)}
>
<div style={{ width: "100%", minHeight: "2rem", cursor: "pointer" }} onClick={() => !disabled && setEditing(true)}>
<Space wrap>
{jobline.location ? (
<Tag>{jobline.location}</Tag>
) : (
<span style={{ opacity: 0.6 }}>{t("general.labels.none")}</span>
)}
{jobline.location}
{jobline.parts_dispatch_lines?.length > 0 && "-Disp"}
</Space>
</div>

View File

@@ -609,7 +609,7 @@ export function JobsDetailHeaderActions({
<FormDateTimePickerComponent
onBlur={() => {
const start = form.getFieldValue("start");
form.setFieldsValue({ end: start?.add(30, "minutes") });
form.setFieldsValue({ end: start.add(30, "minutes") });
}}
/>
</Form.Item>

View File

@@ -1,4 +1,4 @@
import { FileExcelFilled, SyncOutlined } from "@ant-design/icons";
import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Col, Row, Space } from "antd";
import { useEffect, useState } from "react";
import { Gallery } from "react-grid-gallery";
@@ -185,6 +185,21 @@ export function JobsDocumentsLocalGallery({
</Col>
{modalState.open && (
<Lightbox
toolbarButtons={[
<EditFilled
key="edit"
onClick={() => {
const newWindow = window.open(
`${window.location.protocol}//${window.location.host}/edit?imageUrl=${
jobMedia.images[modalState.index].fullsize
}&filename=${jobMedia.images[modalState.index].filename}&jobid=${job.id}`,
"_blank",
"noopener,noreferrer"
);
if (newWindow) newWindow.opener = null;
}}
/>
]}
mainSrc={jobMedia.images[modalState.index].fullsize}
nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize}
prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1270,7 +1270,6 @@
"vehicle": "Vehicle"
},
"labels": {
"selected": "Selected",
"settings": "Settings",
"actions": "Actions",
"areyousure": "Are you sure?",
@@ -1492,8 +1491,7 @@
"assign_team": "Assign Team",
"converttolabor": "Convert amount to Labor.",
"dispatchparts": "Dispatch Parts ({{count}})",
"new": "New Line",
"updatelocation": "Update Location"
"new": "New Line"
},
"errors": {
"creating": "Error encountered while creating job line. {{message}}",
@@ -1574,8 +1572,7 @@
"ioucreated": "IOU",
"new": "New Line",
"nostatus": "No Status",
"presets": "Jobline Presets",
"bulk_location_help": "This will set the same location on all selected lines."
"presets": "Jobline Presets"
},
"successes": {
"created": "Job line created successfully.",

View File

@@ -1270,7 +1270,6 @@
"vehicle": ""
},
"labels": {
"selected": "",
"actions": "Comportamiento",
"settings": "",
"areyousure": "",
@@ -1492,8 +1491,7 @@
"assign_team": "",
"converttolabor": "",
"dispatchparts": "",
"new": "",
"updatelocation": ""
"new": ""
},
"errors": {
"creating": "",
@@ -1574,8 +1572,7 @@
"ioucreated": "",
"new": "Nueva línea",
"nostatus": "",
"presets": "",
"bulk_location_help": ""
"presets": ""
},
"successes": {
"created": "",

View File

@@ -1270,7 +1270,6 @@
"vehicle": ""
},
"labels": {
"selected": "",
"settings": "",
"actions": "actes",
"areyousure": "",
@@ -1492,8 +1491,7 @@
"assign_team": "",
"converttolabor": "",
"dispatchparts": "",
"new": "",
"updatelocation": ""
"new": ""
},
"errors": {
"creating": "",
@@ -1574,8 +1572,7 @@
"ioucreated": "",
"new": "Nouvelle ligne",
"nostatus": "",
"presets": "",
"bulk_location_help": ""
"presets": ""
},
"successes": {
"created": "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,25 +44,25 @@ const generateSignedUploadUrls = async (req, res) => {
for (const filename of filenames) {
const key = filename;
const client = new S3Client({ region: InstanceRegion() });
// Check if filename indicates PDF and set content type accordingly
const isPdf = filename.toLowerCase().endsWith('.pdf');
const isPdf = filename.toLowerCase().endsWith(".pdf");
const commandParams = {
Bucket: imgproxyDestinationBucket,
Key: key,
StorageClass: "INTELLIGENT_TIERING"
};
if (isPdf) {
commandParams.ContentType = "application/pdf";
}
const command = new PutObjectCommand(commandParams);
// For PDFs, we need to add conditions to the presigned URL to enforce content type
const presignedUrlOptions = { expiresIn: 360 };
if (isPdf) {
presignedUrlOptions.signableHeaders = new Set(['content-type']);
presignedUrlOptions.signableHeaders = new Set(["content-type"]);
}
const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions);
@@ -265,6 +265,82 @@ const downloadFiles = async (req, res) => {
}
};
/**
* Stream original image content by document ID
* @param req
* @param res
* @returns {Promise<*>}
*/
const getOriginalImageByDocumentId = async (req, res) => {
const {
body: { documentId },
user,
userGraphQLClient
} = req;
if (!documentId) {
return res.status(400).json({ message: "documentId is required" });
}
try {
logger.log("imgproxy-original-image", "DEBUG", user?.email, null, { documentId });
const { documents } = await userGraphQLClient.request(GET_DOCUMENTS_BY_IDS, { documentIds: [documentId] });
if (!documents || documents.length === 0) {
return res.status(404).json({ message: "Document not found" });
}
const [document] = documents;
const { type } = document;
if (!type || !type.startsWith("image")) {
return res.status(400).json({ message: "Document is not an image" });
}
const s3client = new S3Client({ region: InstanceRegion() });
const key = keyStandardize(document);
let s3Response;
try {
s3Response = await s3client.send(
new GetObjectCommand({
Bucket: imgproxyDestinationBucket,
Key: key
})
);
} catch (err) {
logger.log("imgproxy-original-image-s3-error", "ERROR", user?.email, null, {
key,
message: err.message,
stack: err.stack
});
return res.status(400).json({ message: "Unable to retrieve image" });
}
res.setHeader("Content-Type", type || "image/jpeg");
s3Response.Body.on("error", (err) => {
logger.log("imgproxy-original-image-s3stream-error", "ERROR", user?.email, null, {
key,
message: err.message,
stack: err.stack
});
res.destroy(err);
});
s3Response.Body.pipe(res);
} catch (error) {
logger.log("imgproxy-original-image-error", "ERROR", req.user?.email, null, {
documentId,
message: error.message,
stack: error.stack
});
return res.status(400).json({ message: error.message, stack: error.stack });
}
};
/**
* Delete Files
* @param req
@@ -425,6 +501,7 @@ const keyStandardize = (doc) => {
module.exports = {
generateSignedUploadUrls,
getThumbnailUrls,
getOriginalImageByDocumentId,
downloadFiles,
deleteFiles,
moveFiles

View File

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

View File

@@ -4,6 +4,7 @@ const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = requir
const {
generateSignedUploadUrls: createSignedUploadURLImgproxy,
getThumbnailUrls: getThumbnailUrlsImgproxy,
getOriginalImageByDocumentId: getOriginalImageByDocumentIdImgproxy,
downloadFiles: downloadFilesImgproxy,
moveFiles: moveFilesImgproxy,
deleteFiles: deleteFilesImgproxy
@@ -24,5 +25,6 @@ router.post("/imgproxy/thumbnails", getThumbnailUrlsImgproxy);
router.post("/imgproxy/download", downloadFilesImgproxy);
router.post("/imgproxy/rename", moveFilesImgproxy);
router.post("/imgproxy/delete", deleteFilesImgproxy);
router.post("/imgproxy/original", getOriginalImageByDocumentIdImgproxy);
module.exports = router;