Compare commits
4 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfe0afd4f3 | ||
|
|
c675a328a8 | ||
|
|
6eb432b5b7 | ||
|
|
56d50b855b |
@@ -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);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
//import "tui-image-editor/dist/tui-image-editor.css";
|
//import "tui-image-editor/dist/tui-image-editor.css";
|
||||||
|
import axios from "axios";
|
||||||
import { Result } from "antd";
|
import { Result } from "antd";
|
||||||
import * as markerjs2 from "markerjs2";
|
import * as markerjs2 from "markerjs2";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@@ -6,8 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import { handleUpload } from "../documents-upload/documents-upload.utility";
|
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
|
||||||
import { GenerateSrcUrl } from "../jobs-documents-gallery/job-documents.utility";
|
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
const imgRef = useRef(null);
|
const imgRef = useRef(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploaded, setuploaded] = 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 markerArea = useRef(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
@@ -55,7 +58,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imgRef.current !== null) {
|
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
||||||
// create a marker.js MarkerArea
|
// create a marker.js MarkerArea
|
||||||
markerArea.current = new markerjs2.MarkerArea(imgRef.current);
|
markerArea.current = new markerjs2.MarkerArea(imgRef.current);
|
||||||
|
|
||||||
@@ -78,7 +81,52 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
//markerArea.current.settings.displayMode = "inline";
|
//markerArea.current.settings.displayMode = "inline";
|
||||||
markerArea.current.show();
|
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) {
|
async function b64toBlob(url) {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@@ -87,16 +135,21 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!loading && !uploaded && (
|
{!loading && !uploaded && imageUrl && (
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
src={GenerateSrcUrl(document)}
|
src={imageUrl}
|
||||||
alt="sample"
|
alt="sample"
|
||||||
crossOrigin="anonymous"
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error("Failed to load original image", error);
|
||||||
|
}}
|
||||||
style={{ maxWidth: "90vw", maxHeight: "90vh" }}
|
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")} />}
|
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { setBodyshop } from "../../redux/user/user.actions";
|
|||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
import DocumentEditor from "./document-editor.component";
|
import DocumentEditor from "./document-editor.component";
|
||||||
|
import { DocumentEditorLocalComponent } from "./document-editor-local.component";
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setBodyshop: (bs) => dispatch(setBodyshop(bs))
|
setBodyshop: (bs) => dispatch(setBodyshop(bs))
|
||||||
@@ -21,7 +22,7 @@ export default connect(null, mapDispatchToProps)(DocumentEditorContainer);
|
|||||||
export function DocumentEditorContainer({ setBodyshop }) {
|
export function DocumentEditorContainer({ setBodyshop }) {
|
||||||
//Get the image details for the image to be saved.
|
//Get the image details for the image to be saved.
|
||||||
//Get the document id from the search string.
|
//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 { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
loading: loadingShop,
|
loading: loadingShop,
|
||||||
@@ -32,24 +33,45 @@ export function DocumentEditorContainer({ setBodyshop }) {
|
|||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const isLocalMedia = !!dataShop?.bodyshops?.[0]?.uselocalmediaserver;
|
||||||
if (dataShop) setBodyshop(dataShop.bodyshops[0]);
|
|
||||||
}, [dataShop, setBodyshop]);
|
|
||||||
|
|
||||||
const { loading, error, data } = useQuery(GET_DOCUMENT_BY_PK, {
|
const {
|
||||||
|
loading: loadingDoc,
|
||||||
|
error: errorDoc,
|
||||||
|
data: dataDoc
|
||||||
|
} = useQuery(GET_DOCUMENT_BY_PK, {
|
||||||
variables: { documentId },
|
variables: { documentId },
|
||||||
skip: !documentId,
|
skip: !documentId || isLocalMedia,
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading || loadingShop) return <LoadingSpinner />;
|
useEffect(() => {
|
||||||
if (error || errorShop) return <AlertComponent message={error.message || errorShop.message} type="error" />;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DocumentEditor document={data ? data.documents_by_pk : null} />
|
<DocumentEditor document={dataDoc ? dataDoc.documents_by_pk : null} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Alert, Button, Card, Col, Row, Space } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Gallery } from "react-grid-gallery";
|
import { Gallery } from "react-grid-gallery";
|
||||||
@@ -185,6 +185,21 @@ export function JobsDocumentsLocalGallery({
|
|||||||
</Col>
|
</Col>
|
||||||
{modalState.open && (
|
{modalState.open && (
|
||||||
<Lightbox
|
<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}
|
mainSrc={jobMedia.images[modalState.index].fullsize}
|
||||||
nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize}
|
nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize}
|
||||||
prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize}
|
prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
|
|||||||
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
|
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
|
||||||
{employeeOptions.length > 0 ? (
|
{employeeOptions.length > 0 ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
|
|
||||||
name="notification_followers"
|
name="notification_followers"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
@@ -43,6 +42,11 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
|
|||||||
options={employeeOptions}
|
options={employeeOptions}
|
||||||
placeholder={t("bodyshop.fields.notifications.placeholder")}
|
placeholder={t("bodyshop.fields.notifications.placeholder")}
|
||||||
showEmail={true}
|
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>
|
</Form.Item>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1156,11 +1156,7 @@
|
|||||||
enable_manual: false
|
enable_manual: false
|
||||||
update:
|
update:
|
||||||
columns:
|
columns:
|
||||||
- imexshopid
|
|
||||||
- timezone
|
|
||||||
- shopname
|
- shopname
|
||||||
- notification_followers
|
|
||||||
- state
|
|
||||||
- md_order_statuses
|
- md_order_statuses
|
||||||
retry_conf:
|
retry_conf:
|
||||||
interval_sec: 10
|
interval_sec: 10
|
||||||
@@ -3702,7 +3698,6 @@
|
|||||||
- deliverchecklist
|
- deliverchecklist
|
||||||
- depreciation_taxes
|
- depreciation_taxes
|
||||||
- dms_allocation
|
- dms_allocation
|
||||||
- dms_id
|
|
||||||
- driveable
|
- driveable
|
||||||
- employee_body
|
- employee_body
|
||||||
- employee_csr
|
- employee_csr
|
||||||
@@ -3980,7 +3975,6 @@
|
|||||||
- deliverchecklist
|
- deliverchecklist
|
||||||
- depreciation_taxes
|
- depreciation_taxes
|
||||||
- dms_allocation
|
- dms_allocation
|
||||||
- dms_id
|
|
||||||
- driveable
|
- driveable
|
||||||
- employee_body
|
- employee_body
|
||||||
- employee_csr
|
- employee_csr
|
||||||
@@ -4270,7 +4264,6 @@
|
|||||||
- deliverchecklist
|
- deliverchecklist
|
||||||
- depreciation_taxes
|
- depreciation_taxes
|
||||||
- dms_allocation
|
- dms_allocation
|
||||||
- dms_id
|
|
||||||
- driveable
|
- driveable
|
||||||
- employee_body
|
- employee_body
|
||||||
- employee_csr
|
- employee_csr
|
||||||
|
|||||||
@@ -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 = `
|
exports.GET_DOCUMENTS_BY_JOB = `
|
||||||
query GET_DOCUMENTS_BY_JOB($jobId: uuid!) {
|
query GET_DOCUMENTS_BY_JOB($jobId: uuid!) {
|
||||||
jobs_by_pk(id: $jobId) {
|
jobs_by_pk(id: $jobId) {
|
||||||
|
|||||||
@@ -241,8 +241,6 @@ const partsManagementProvisioning = async (req, res) => {
|
|||||||
"phone",
|
"phone",
|
||||||
"userEmail"
|
"userEmail"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// TODO add in check for early access
|
|
||||||
await ensureExternalIdUnique(body.external_shop_id);
|
await ensureExternalIdUnique(body.external_shop_id);
|
||||||
|
|
||||||
logger.log("admin-create-shop-user", "debug", body.userEmail, null, {
|
logger.log("admin-create-shop-user", "debug", body.userEmail, null, {
|
||||||
|
|||||||
@@ -44,25 +44,25 @@ const generateSignedUploadUrls = async (req, res) => {
|
|||||||
for (const filename of filenames) {
|
for (const filename of filenames) {
|
||||||
const key = filename;
|
const key = filename;
|
||||||
const client = new S3Client({ region: InstanceRegion() });
|
const client = new S3Client({ region: InstanceRegion() });
|
||||||
|
|
||||||
// Check if filename indicates PDF and set content type accordingly
|
// Check if filename indicates PDF and set content type accordingly
|
||||||
const isPdf = filename.toLowerCase().endsWith('.pdf');
|
const isPdf = filename.toLowerCase().endsWith(".pdf");
|
||||||
const commandParams = {
|
const commandParams = {
|
||||||
Bucket: imgproxyDestinationBucket,
|
Bucket: imgproxyDestinationBucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
StorageClass: "INTELLIGENT_TIERING"
|
StorageClass: "INTELLIGENT_TIERING"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
commandParams.ContentType = "application/pdf";
|
commandParams.ContentType = "application/pdf";
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = new PutObjectCommand(commandParams);
|
const command = new PutObjectCommand(commandParams);
|
||||||
|
|
||||||
// For PDFs, we need to add conditions to the presigned URL to enforce content type
|
// For PDFs, we need to add conditions to the presigned URL to enforce content type
|
||||||
const presignedUrlOptions = { expiresIn: 360 };
|
const presignedUrlOptions = { expiresIn: 360 };
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
presignedUrlOptions.signableHeaders = new Set(['content-type']);
|
presignedUrlOptions.signableHeaders = new Set(["content-type"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions);
|
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
|
* Delete Files
|
||||||
* @param req
|
* @param req
|
||||||
@@ -425,6 +501,7 @@ const keyStandardize = (doc) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
generateSignedUploadUrls,
|
generateSignedUploadUrls,
|
||||||
getThumbnailUrls,
|
getThumbnailUrls,
|
||||||
|
getOriginalImageByDocumentId,
|
||||||
downloadFiles,
|
downloadFiles,
|
||||||
deleteFiles,
|
deleteFiles,
|
||||||
moveFiles
|
moveFiles
|
||||||
|
|||||||
@@ -4,14 +4,11 @@
|
|||||||
* This module handles automatically adding watchers to new jobs based on the notifications_autoadd
|
* 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.
|
* 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.
|
* 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 { client: gqlClient } = require("../graphql-client/graphql-client");
|
||||||
const { isEmpty } = require("lodash");
|
const { isEmpty } = require("lodash");
|
||||||
const {
|
const {
|
||||||
GET_BODYSHOP_WATCHERS_BY_ID,
|
|
||||||
GET_JOB_WATCHERS_MINIMAL,
|
GET_JOB_WATCHERS_MINIMAL,
|
||||||
GET_NOTIFICATION_WATCHERS,
|
GET_NOTIFICATION_WATCHERS,
|
||||||
INSERT_JOB_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 autoAddWatchers = async (req) => {
|
||||||
const { event, trigger } = req.body;
|
const { event, trigger } = req.body;
|
||||||
const { logger } = req;
|
const {
|
||||||
|
logger,
|
||||||
|
sessionUtils: { getBodyshopFromRedis }
|
||||||
|
} = req;
|
||||||
|
|
||||||
// Validate that this is an INSERT event, bail
|
// Validate that this is an INSERT event, bail
|
||||||
if (trigger?.name !== "notifications_jobs_autoadd" || event.op !== "INSERT" || event.data.old) {
|
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"];
|
const hasuraUserId = event?.session_variables?.["x-hasura-user-id"];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch bodyshop data directly from DB (avoid Redis staleness)
|
// Fetch bodyshop data from Redis
|
||||||
const bodyshopResponse = await gqlClient.request(GET_BODYSHOP_WATCHERS_BY_ID, { id: shopId });
|
const bodyshopData = await getBodyshopFromRedis(shopId);
|
||||||
const bodyshopData = bodyshopResponse?.bodyshops_by_pk;
|
let notificationFollowers = bodyshopData?.notification_followers;
|
||||||
|
|
||||||
const notificationFollowersRaw = bodyshopData?.notification_followers;
|
// Bail if notification_followers is missing or not an array
|
||||||
const notificationFollowers = Array.isArray(notificationFollowersRaw)
|
if (!notificationFollowers || !Array.isArray(notificationFollowers)) {
|
||||||
? [...new Set(notificationFollowersRaw.filter((id) => id))] // de-dupe + remove falsy
|
return;
|
||||||
: [];
|
}
|
||||||
|
|
||||||
// Execute queries in parallel
|
// Execute queries in parallel
|
||||||
const [notificationData, existingWatchersData] = await Promise.all([
|
const [notificationData, existingWatchersData] = await Promise.all([
|
||||||
gqlClient.request(GET_NOTIFICATION_WATCHERS, {
|
gqlClient.request(GET_NOTIFICATION_WATCHERS, {
|
||||||
shopId,
|
shopId,
|
||||||
employeeIds: notificationFollowers
|
employeeIds: notificationFollowers.filter((id) => id)
|
||||||
}),
|
}),
|
||||||
gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId })
|
gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId })
|
||||||
]);
|
]);
|
||||||
@@ -73,7 +73,7 @@ const autoAddWatchers = async (req) => {
|
|||||||
associationId: assoc.id
|
associationId: assoc.id
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Get users from notification_followers (employee IDs -> employee emails)
|
// Get users from notification_followers
|
||||||
const followerEmails =
|
const followerEmails =
|
||||||
notificationData?.employees
|
notificationData?.employees
|
||||||
?.filter((e) => e.user_email)
|
?.filter((e) => e.user_email)
|
||||||
@@ -84,7 +84,7 @@ const autoAddWatchers = async (req) => {
|
|||||||
|
|
||||||
// Combine and deduplicate emails (use email as the unique key)
|
// Combine and deduplicate emails (use email as the unique key)
|
||||||
const usersToAdd = [...autoAddUsers, ...followerEmails].reduce((acc, user) => {
|
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);
|
acc.push(user);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
@@ -123,7 +123,6 @@ const autoAddWatchers = async (req) => {
|
|||||||
message: error?.message,
|
message: error?.message,
|
||||||
stack: error?.stack,
|
stack: error?.stack,
|
||||||
jobId,
|
jobId,
|
||||||
shopId,
|
|
||||||
roNumber
|
roNumber
|
||||||
});
|
});
|
||||||
throw error; // Re-throw to ensure the error is logged in the handler
|
throw error; // Re-throw to ensure the error is logged in the handler
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = requir
|
|||||||
const {
|
const {
|
||||||
generateSignedUploadUrls: createSignedUploadURLImgproxy,
|
generateSignedUploadUrls: createSignedUploadURLImgproxy,
|
||||||
getThumbnailUrls: getThumbnailUrlsImgproxy,
|
getThumbnailUrls: getThumbnailUrlsImgproxy,
|
||||||
|
getOriginalImageByDocumentId: getOriginalImageByDocumentIdImgproxy,
|
||||||
downloadFiles: downloadFilesImgproxy,
|
downloadFiles: downloadFilesImgproxy,
|
||||||
moveFiles: moveFilesImgproxy,
|
moveFiles: moveFilesImgproxy,
|
||||||
deleteFiles: deleteFilesImgproxy
|
deleteFiles: deleteFilesImgproxy
|
||||||
@@ -24,5 +25,6 @@ router.post("/imgproxy/thumbnails", getThumbnailUrlsImgproxy);
|
|||||||
router.post("/imgproxy/download", downloadFilesImgproxy);
|
router.post("/imgproxy/download", downloadFilesImgproxy);
|
||||||
router.post("/imgproxy/rename", moveFilesImgproxy);
|
router.post("/imgproxy/rename", moveFilesImgproxy);
|
||||||
router.post("/imgproxy/delete", deleteFilesImgproxy);
|
router.post("/imgproxy/delete", deleteFilesImgproxy);
|
||||||
|
router.post("/imgproxy/original", getOriginalImageByDocumentIdImgproxy);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user