Merged in feature/IO-3092-imgproxy (pull request #2225)
Feature/IO-3092 imgproxy
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { PictureFilled } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Badge, Popover } 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";
|
||||
@@ -9,6 +10,7 @@ import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
|
||||
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
|
||||
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
|
||||
@@ -23,6 +25,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
|
||||
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
treatments: { Imgproxy }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Imgproxy"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const { loading, error, data } = useQuery(GET_DOCUMENTS_BY_JOB, {
|
||||
fetchPolicy: "network-only",
|
||||
@@ -42,6 +51,10 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
setSelectedMedia([]);
|
||||
}, [setSelectedMedia, conversation]);
|
||||
|
||||
//Knowingly taking on the technical debt of poor implementation below. Done this way to avoid an edge case where no component may be displayed.
|
||||
//Cloudinary will be removed once the migration is completed.
|
||||
//If Imageproxy is on, rely only on the LMS selector
|
||||
//If not on, use the old methods.
|
||||
const content = (
|
||||
<div>
|
||||
{loading && <LoadingSpinner />}
|
||||
@@ -49,17 +62,37 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
|
||||
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div>
|
||||
) : null}
|
||||
{!bodyshop.uselocalmediaserver && data && (
|
||||
<JobDocumentsGalleryExternal
|
||||
data={data ? data.documents : []}
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
/>
|
||||
)}
|
||||
{bodyshop.uselocalmediaserver && open && (
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
|
||||
/>
|
||||
|
||||
{Imgproxy.treatment === "on" ? (
|
||||
<>
|
||||
{!bodyshop.uselocalmediaserver && (
|
||||
<JobsDocumentImgproxyGalleryExternal
|
||||
jobId={conversation.job_conversations[0].jobid}
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
/>
|
||||
)}
|
||||
{bodyshop.uselocalmediaserver && open && (
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!bodyshop.uselocalmediaserver && data && (
|
||||
<JobDocumentsGalleryExternal
|
||||
data={data ? data.documents : []}
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
/>
|
||||
)}
|
||||
{bodyshop.uselocalmediaserver && open && (
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { UploadOutlined } from "@ant-design/icons";
|
||||
import { Progress, Result, Space, Upload } from "antd";
|
||||
import { useMemo, 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 { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import formatBytes from "../../utils/formatbytes";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||
import { handleUpload } from "./documents-upload-imgproxy.utility.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
export function DocumentsUploadImgproxyComponent({
|
||||
children,
|
||||
currentUser,
|
||||
bodyshop,
|
||||
jobId,
|
||||
tagsArray,
|
||||
billId,
|
||||
callbackAfterUpload,
|
||||
totalSize,
|
||||
ignoreSizeLimit = false
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [fileList, setFileList] = useState([]);
|
||||
const notification = useNotification();
|
||||
|
||||
const pct = useMemo(() => {
|
||||
return parseInt((totalSize / ((bodyshop && bodyshop.jobsizelimit) || 1)) * 100);
|
||||
}, [bodyshop, totalSize]);
|
||||
|
||||
if (pct > 100 && !ignoreSizeLimit)
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title={t("documents.labels.storageexceeded_title")}
|
||||
subTitle={t("documents.labels.storageexceeded")}
|
||||
/>
|
||||
);
|
||||
|
||||
const handleDone = (uid) => {
|
||||
setTimeout(() => {
|
||||
setFileList((fileList) => fileList.filter((x) => x.uid !== uid));
|
||||
}, 2000);
|
||||
};
|
||||
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
||||
|
||||
return (
|
||||
<Upload.Dragger
|
||||
multiple={true}
|
||||
fileList={fileList}
|
||||
disabled={!hasMediaAccess}
|
||||
onChange={(f) => {
|
||||
if (f.event && f.event.percent === 100) handleDone(f.file.uid);
|
||||
setFileList(f.fileList);
|
||||
}}
|
||||
beforeUpload={(file, fileList) => {
|
||||
if (ignoreSizeLimit) return true;
|
||||
const newFiles = fileList.reduce((acc, val) => acc + val.size, 0);
|
||||
const shouldStopUpload = (totalSize + newFiles) / ((bodyshop && bodyshop.jobsizelimit) || 1) >= 1;
|
||||
|
||||
//Check to see if old files plus newly uploaded ones will be too much.
|
||||
if (shouldStopUpload) {
|
||||
notification.error({
|
||||
key: "cannotuploaddocuments",
|
||||
message: t("documents.labels.upload_limitexceeded_title"),
|
||||
description: t("documents.labels.upload_limitexceeded")
|
||||
});
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
customRequest={(ev) =>
|
||||
handleUpload(
|
||||
ev,
|
||||
{
|
||||
bodyshop: bodyshop,
|
||||
uploaded_by: currentUser.email,
|
||||
jobId: jobId,
|
||||
billId: billId,
|
||||
tagsArray: tagsArray,
|
||||
callback: callbackAfterUpload
|
||||
},
|
||||
notification
|
||||
)
|
||||
}
|
||||
accept="audio/*, video/*, image/*, .pdf, .doc, .docx, .xls, .xlsx"
|
||||
// showUploadList={false}
|
||||
>
|
||||
{children || (
|
||||
<>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
<LockWrapperComponent featureName="media">{t("documents.labels.dragtoupload")}</LockWrapperComponent>
|
||||
</p>
|
||||
{!ignoreSizeLimit && (
|
||||
<Space wrap className="ant-upload-text">
|
||||
<Progress type="dashboard" percent={pct} size="small" />
|
||||
<span>
|
||||
{t("documents.labels.usage", {
|
||||
percent: pct,
|
||||
used: formatBytes(totalSize),
|
||||
total: formatBytes(bodyshop && bodyshop.jobsizelimit)
|
||||
})}
|
||||
</span>
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Upload.Dragger>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(DocumentsUploadImgproxyComponent);
|
||||
@@ -0,0 +1,172 @@
|
||||
import axios from "axios";
|
||||
import exifr from "exifr";
|
||||
import i18n from "i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
|
||||
import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
|
||||
import client from "../../utils/GraphQLClient";
|
||||
|
||||
//Context: currentUserEmail, bodyshop, jobid, invoiceid
|
||||
|
||||
//Required to prevent headers from getting set and rejected from Cloudinary.
|
||||
var cleanAxios = axios.create();
|
||||
cleanAxios.interceptors.request.eject(axiosAuthInterceptorId);
|
||||
|
||||
export const handleUpload = (ev, context, notification) => {
|
||||
logImEXEvent("document_upload", { filetype: ev.file?.type });
|
||||
|
||||
const { onError, onSuccess, onProgress } = ev;
|
||||
const { bodyshop, jobId } = context;
|
||||
|
||||
const fileName = ev.file?.name || ev.filename;
|
||||
|
||||
let extension = fileName.split(".").pop();
|
||||
let key = `${bodyshop.id}/${jobId}/${replaceAccents(fileName).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.${extension}`;
|
||||
|
||||
uploadToS3(key, extension, ev.file.type, ev.file, onError, onSuccess, onProgress, context, notification).catch(
|
||||
(error) => {
|
||||
console.error("Error uploading file to S3", error);
|
||||
notification.error({
|
||||
message: i18n.t("documents.errors.insert", {
|
||||
message: error.message
|
||||
})
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
//Handles only 1 file at a time.
|
||||
export const uploadToS3 = async (
|
||||
key,
|
||||
extension,
|
||||
fileType,
|
||||
file,
|
||||
onError,
|
||||
onSuccess,
|
||||
onProgress,
|
||||
context,
|
||||
notification
|
||||
) => {
|
||||
const { bodyshop, jobId, billId, uploaded_by, callback } = context;
|
||||
|
||||
//Get the signed url allowing us to PUT to S3.
|
||||
const signedURLResponse = await axios.post("/media/imgproxy/sign", {
|
||||
filenames: [key],
|
||||
bodyshopid: bodyshop.id,
|
||||
jobid: jobId
|
||||
});
|
||||
|
||||
if (signedURLResponse.status !== 200) {
|
||||
if (onError) onError(signedURLResponse.statusText);
|
||||
notification.error({
|
||||
message: i18n.t("documents.errors.getpresignurl", {
|
||||
message: signedURLResponse.statusText
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//Key should be same as we provided to maintain backwards compatibility.
|
||||
const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } = signedURLResponse.data.signedUrls[0];
|
||||
|
||||
const options = {
|
||||
onUploadProgress: (e) => {
|
||||
if (onProgress) onProgress({ percent: (e.loaded / e.total) * 100 });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const s3UploadResponse = await cleanAxios.put(preSignedUploadUrlToS3, file, options);
|
||||
//Insert the document with the matching key.
|
||||
let takenat;
|
||||
if (fileType.includes("image")) {
|
||||
try {
|
||||
const exif = await exifr.parse(file);
|
||||
takenat = exif && exif.DateTimeOriginal;
|
||||
} catch (error) {
|
||||
console.log("Unable to parse image file for EXIF Data", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const documentInsert = await client.mutate({
|
||||
mutation: INSERT_NEW_DOCUMENT,
|
||||
variables: {
|
||||
docInput: [
|
||||
{
|
||||
...(jobId ? { jobid: jobId } : {}),
|
||||
...(billId ? { billid: billId } : {}),
|
||||
uploaded_by: uploaded_by,
|
||||
key: s3Key,
|
||||
type: fileType,
|
||||
extension: s3UploadResponse.data.format || extension,
|
||||
bodyshopid: bodyshop.id,
|
||||
size: s3UploadResponse.data.bytes || file.size, //Leftover from Cloudinary. We don't do any optimization on upload, so it will always be file.size.
|
||||
takenat
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!documentInsert.errors) {
|
||||
if (onSuccess)
|
||||
onSuccess({
|
||||
uid: documentInsert.data.insert_documents.returning[0].id,
|
||||
name: documentInsert.data.insert_documents.returning[0].name,
|
||||
status: "done",
|
||||
key: documentInsert.data.insert_documents.returning[0].key
|
||||
});
|
||||
notification.success({
|
||||
key: "docuploadsuccess",
|
||||
message: i18n.t("documents.successes.insert")
|
||||
});
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
if (onError) onError(JSON.stringify(documentInsert.errors));
|
||||
notification.error({
|
||||
message: i18n.t("documents.errors.insert", {
|
||||
message: JSON.stringify(documentInsert.errors)
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error uploading file to S3", error.message, error.stack);
|
||||
notification.error({
|
||||
message: i18n.t("documents.errors.insert", {
|
||||
message: error.message
|
||||
})
|
||||
});
|
||||
if (onError) onError(JSON.stringify(error.message));
|
||||
}
|
||||
};
|
||||
|
||||
function replaceAccents(str) {
|
||||
// Verifies if the String has accents and replace them
|
||||
if (str.search(/[\xC0-\xFF]/g) > -1) {
|
||||
str = str
|
||||
.replace(/[\xC0-\xC5]/g, "A")
|
||||
.replace(/[\xC6]/g, "AE")
|
||||
.replace(/[\xC7]/g, "C")
|
||||
.replace(/[\xC8-\xCB]/g, "E")
|
||||
.replace(/[\xCC-\xCF]/g, "I")
|
||||
.replace(/[\xD0]/g, "D")
|
||||
.replace(/[\xD1]/g, "N")
|
||||
.replace(/[\xD2-\xD6\xD8]/g, "O")
|
||||
.replace(/[\xD9-\xDC]/g, "U")
|
||||
.replace(/[\xDD]/g, "Y")
|
||||
.replace(/[\xDE]/g, "P")
|
||||
.replace(/[\xE0-\xE5]/g, "a")
|
||||
.replace(/[\xE6]/g, "ae")
|
||||
.replace(/[\xE7]/g, "c")
|
||||
.replace(/[\xE8-\xEB]/g, "e")
|
||||
.replace(/[\xEC-\xEF]/g, "i")
|
||||
.replace(/[\xF1]/g, "n")
|
||||
.replace(/[\xF2-\xF6\xF8]/g, "o")
|
||||
.replace(/[\xF9-\xFC]/g, "u")
|
||||
.replace(/[\xFE]/g, "p")
|
||||
.replace(/[\xFD\xFF]/g, "y");
|
||||
}
|
||||
return str;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import AlertComponent from "../alert/alert.component";
|
||||
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
|
||||
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
@@ -23,6 +25,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(EmailDocumentsCompon
|
||||
|
||||
export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
treatments: { Imgproxy }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Imgproxy"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const [selectedMedia, setSelectedMedia] = selectedMediaState;
|
||||
const { loading, error, data } = useQuery(GET_DOCUMENTS_BY_JOB, {
|
||||
@@ -46,17 +55,37 @@ export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState,
|
||||
10485760 - new Blob([form.getFieldValue("html")]).size ? (
|
||||
<div style={{ color: "red" }}>{t("general.errors.sizelimit")}</div>
|
||||
) : null}
|
||||
{!bodyshop.uselocalmediaserver && data && (
|
||||
<JobDocumentsGalleryExternal
|
||||
data={data ? data.documents : []}
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
/>
|
||||
)}
|
||||
{bodyshop.uselocalmediaserver && (
|
||||
<JobsDocumentsLocalGalleryExternalComponent
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={emailConfig.jobid}
|
||||
/>
|
||||
|
||||
{Imgproxy.treatment === "on" ? (
|
||||
<>
|
||||
{!bodyshop.uselocalmediaserver && data && (
|
||||
<JobsDocumentImgproxyGalleryExternal
|
||||
jobId={emailConfig.jobid}
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
/>
|
||||
)}
|
||||
{bodyshop.uselocalmediaserver && (
|
||||
<JobsDocumentsLocalGalleryExternalComponent
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={emailConfig.jobid}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!bodyshop.uselocalmediaserver && data && (
|
||||
<JobDocumentsGalleryExternal
|
||||
data={data ? data.documents : []}
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
/>
|
||||
)}
|
||||
{bodyshop.uselocalmediaserver && (
|
||||
<JobsDocumentsLocalGalleryExternalComponent
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={emailConfig.jobid}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,13 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsDownloadButton);
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
|
||||
################################################################################################
|
||||
*/
|
||||
export function JobsDocumentsDownloadButton({ bodyshop, galleryImages, identifier }) {
|
||||
const { t } = useTranslation();
|
||||
const [download, setDownload] = useState(null);
|
||||
|
||||
@@ -17,7 +17,13 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsGalleryReassign);
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
|
||||
################################################################################################
|
||||
*/
|
||||
export function JobsDocumentsGalleryReassign({ bodyshop, galleryImages, callback }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Col, Row, Space } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Gallery } from "react-grid-gallery";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Lightbox from "react-image-lightbox";
|
||||
import "react-image-lightbox/style.css";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import DocumentsUploadComponent from "../documents-upload/documents-upload.component";
|
||||
import { DetermineFileType } from "../documents-upload/documents-upload.utility";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||
import { GenerateSrcUrl, GenerateThumbUrl } from "./job-documents.utility";
|
||||
import JobsDocumentsDownloadButton from "./jobs-document-gallery.download.component";
|
||||
import JobsDocumentsGalleryReassign from "./jobs-document-gallery.reassign.component";
|
||||
import JobsDocumentsDeleteButton from "./jobs-documents-gallery.delete.component";
|
||||
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-gallery.selectall.component";
|
||||
|
||||
import Lightbox from "react-image-lightbox";
|
||||
import "react-image-lightbox/style.css";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
|
||||
################################################################################################
|
||||
*/
|
||||
function JobsDocumentsComponent({
|
||||
bodyshop,
|
||||
data,
|
||||
@@ -114,6 +119,7 @@ function JobsDocumentsComponent({
|
||||
);
|
||||
setgalleryImages(documents);
|
||||
}, [data, setgalleryImages, t]);
|
||||
|
||||
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
||||
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
|
||||
return (
|
||||
@@ -137,7 +143,6 @@ function JobsDocumentsComponent({
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
<Col span={24}>
|
||||
<Card>
|
||||
<DocumentsUploadComponent
|
||||
|
||||
@@ -2,29 +2,77 @@ import { useQuery } from "@apollo/client";
|
||||
import React from "react";
|
||||
import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import JobDocumentsImgProxy from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import JobDocuments from "./jobs-documents-gallery.component";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsContainer);
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
|
||||
################################################################################################
|
||||
*/
|
||||
export function JobsDocumentsContainer({
|
||||
jobId,
|
||||
billId,
|
||||
documentsList,
|
||||
billsCallback,
|
||||
refetchOverride,
|
||||
ignoreSizeLimit,
|
||||
bodyshop
|
||||
}) {
|
||||
const {
|
||||
treatments: { Imgproxy }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Imgproxy"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
});
|
||||
|
||||
export default function JobsDocumentsContainer({ jobId, billId, documentsList, billsCallback }) {
|
||||
const { loading, error, data, refetch } = useQuery(GET_DOCUMENTS_BY_JOB, {
|
||||
variables: { jobId: jobId },
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: !!billId
|
||||
skip: Imgproxy.treatment === "on" || !!billId
|
||||
});
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <AlertComponent type="error" message={error.message} />;
|
||||
|
||||
return (
|
||||
<JobDocuments
|
||||
data={(data && data.documents) || documentsList || []}
|
||||
downloadIdentifier={data && data.jobs_by_pk.ro_number}
|
||||
totalSize={data && data.documents_aggregate.aggregate.sum.size}
|
||||
billId={billId}
|
||||
jobId={jobId}
|
||||
refetch={refetch}
|
||||
billsCallback={billsCallback}
|
||||
/>
|
||||
);
|
||||
if (Imgproxy.treatment === "on") {
|
||||
return (
|
||||
<JobDocumentsImgProxy
|
||||
data={(data && data.documents) || documentsList || []}
|
||||
downloadIdentifier={data && data.jobs_by_pk.ro_number}
|
||||
totalSize={data && data.documents_aggregate.aggregate.sum.size}
|
||||
billId={billId}
|
||||
jobId={jobId}
|
||||
refetch={refetchOverride || refetch}
|
||||
billsCallback={billsCallback}
|
||||
ignoreSizeLimit={ignoreSizeLimit}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<JobDocuments
|
||||
data={(data && data.documents) || documentsList || []}
|
||||
downloadIdentifier={data && data.jobs_by_pk.ro_number}
|
||||
totalSize={data && data.documents_aggregate.aggregate.sum.size}
|
||||
billId={billId}
|
||||
jobId={jobId}
|
||||
refetch={refetchOverride || refetch}
|
||||
billsCallback={billsCallback}
|
||||
ignoreSizeLimit={ignoreSizeLimit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
//Context: currentUserEmail, bodyshop, jobid, invoiceid
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
|
||||
################################################################################################
|
||||
*/
|
||||
export default function JobsDocumentsDeleteButton({ galleryImages, deletionCallback }) {
|
||||
const { t } = useTranslation();
|
||||
const notification = useNotification();
|
||||
|
||||
@@ -3,6 +3,13 @@ import { Gallery } from "react-grid-gallery";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GenerateSrcUrl, GenerateThumbUrl } from "./job-documents.utility";
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
|
||||
################################################################################################
|
||||
*/
|
||||
function JobsDocumentGalleryExternal({
|
||||
data,
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@ import { Button, Space } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Imgproxy code. This code will be removed once the imgproxy migration is completed.
|
||||
################################################################################################
|
||||
*/
|
||||
|
||||
export default function JobsDocumentsGallerySelectAllComponent({ galleryImages, setGalleryImages }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import React, { 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";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
|
||||
################################################################################################
|
||||
*/
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton);
|
||||
|
||||
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier }) {
|
||||
const { t } = useTranslation();
|
||||
const [download, setDownload] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const imagesToDownload = [
|
||||
...galleryImages.images.filter((image) => image.isSelected),
|
||||
...galleryImages.other.filter((image) => image.isSelected)
|
||||
];
|
||||
|
||||
function downloadProgress(progressEvent) {
|
||||
setDownload((currentDownloadState) => {
|
||||
return {
|
||||
downloaded: progressEvent.loaded || 0,
|
||||
speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
function standardMediaDownload(bufferData) {
|
||||
const a = document.createElement("a");
|
||||
const url = window.URL.createObjectURL(new Blob([bufferData]));
|
||||
a.href = url;
|
||||
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) }
|
||||
});
|
||||
|
||||
const theDownloadedZip = await cleanAxios({
|
||||
url: zipUrl.data.url,
|
||||
method: "GET",
|
||||
responseType: "arraybuffer",
|
||||
onDownloadProgress: downloadProgress
|
||||
});
|
||||
setLoading(false);
|
||||
setDownload(null);
|
||||
|
||||
standardMediaDownload(theDownloadedZip.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button loading={download || loading} disabled={imagesToDownload.length < 1} onClick={handleDownload}>
|
||||
<Space>
|
||||
<span>{t("documents.actions.download")}</span>
|
||||
{download && <span>{`(${formatBytes(download.downloaded)} @ ${formatBytes(download.speed)} / second)`}</span>}
|
||||
</Space>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import { Button, Form, Popover, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import { useMemo, 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_DOC_SIZE_BY_JOB } from "../../graphql/documents.queries.js";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import JobSearchSelect from "../job-search-select/job-search-select.component.jsx";
|
||||
import { isFunction } from "lodash";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyGalleryReassign);
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
|
||||
################################################################################################
|
||||
*/
|
||||
export function JobsDocumentsImgproxyGalleryReassign({ bodyshop, galleryImages, callback }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const notification = useNotification();
|
||||
|
||||
const selectedImages = useMemo(() => {
|
||||
return [
|
||||
...galleryImages.images.filter((image) => image.isSelected),
|
||||
...galleryImages.other.filter((image) => image.isSelected)
|
||||
];
|
||||
}, [galleryImages]);
|
||||
const client = useApolloClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleFinish = async ({ jobid }) => {
|
||||
setLoading(true);
|
||||
|
||||
//Check to see if the space remaining on the new job is sufficient. If it isn't cancel this.
|
||||
const newJobData = await client.query({
|
||||
query: GET_DOC_SIZE_BY_JOB,
|
||||
variables: { jobId: jobid }
|
||||
});
|
||||
|
||||
const transferedDocSizeTotal = selectedImages.reduce((acc, val) => acc + val.size, 0);
|
||||
|
||||
const shouldPreventTransfer =
|
||||
bodyshop.jobsizelimit - newJobData.data.documents_aggregate.aggregate.sum.size < transferedDocSizeTotal;
|
||||
|
||||
if (shouldPreventTransfer) {
|
||||
notification.error({
|
||||
key: "cannotuploaddocuments",
|
||||
message: t("documents.labels.reassign_limitexceeded_title"),
|
||||
description: t("documents.labels.reassign_limitexceeded")
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await axios.post("/media/imgproxy/rename", {
|
||||
tojobid: jobid,
|
||||
documents: selectedImages.map((i) => {
|
||||
//Need to check if the current key folder is null, or another job.
|
||||
const currentKeys = i.key.split("/");
|
||||
currentKeys[1] = jobid;
|
||||
currentKeys.join("/");
|
||||
return {
|
||||
id: i.id,
|
||||
from: i.key,
|
||||
to: currentKeys.join("/"),
|
||||
extension: i.extension,
|
||||
type: i.type
|
||||
};
|
||||
})
|
||||
});
|
||||
//Add in confirmation & errors.
|
||||
if (isFunction(callback)) callback();
|
||||
|
||||
if (res.errors) {
|
||||
notification.error({
|
||||
message: t("documents.errors.updating", {
|
||||
message: JSON.stringify(res.errors)
|
||||
})
|
||||
});
|
||||
}
|
||||
if (!res.mutationResult?.errors) {
|
||||
notification.success({
|
||||
message: t("documents.successes.updated")
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const popContent = (
|
||||
<div>
|
||||
<Form onFinish={handleFinish} layout="vertical" form={form}>
|
||||
<Form.Item
|
||||
label={t("documents.labels.newjobid")}
|
||||
style={{ width: "20rem" }}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
name={"jobid"}
|
||||
>
|
||||
<JobSearchSelect notExported={false} notInvoiced={false} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => form.submit()}>
|
||||
{t("general.actions.submit")}
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>{t("general.actions.cancel")}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popContent} open={open}>
|
||||
<Button disabled={selectedImages.length < 1} onClick={() => setOpen(true)} loading={loading}>
|
||||
{t("documents.actions.reassign")}
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Col, Row, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import i18n from "i18next";
|
||||
import { isFunction } from "lodash";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Gallery } from "react-grid-gallery";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Lightbox from "react-image-lightbox";
|
||||
import "react-image-lightbox/style.css";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
|
||||
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
|
||||
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
|
||||
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
|
||||
################################################################################################
|
||||
*/
|
||||
function JobsDocumentsImgproxyComponent({
|
||||
bodyshop,
|
||||
data,
|
||||
jobId,
|
||||
refetch,
|
||||
billId,
|
||||
billsCallback,
|
||||
totalSize,
|
||||
downloadIdentifier,
|
||||
ignoreSizeLimit
|
||||
}) {
|
||||
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
|
||||
const { t } = useTranslation();
|
||||
const [modalState, setModalState] = useState({ open: false, index: 0 });
|
||||
|
||||
const fetchThumbnails = useCallback(() => {
|
||||
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId });
|
||||
}, [jobId, setGalleryImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
fetchThumbnails();
|
||||
}
|
||||
}, [data, fetchThumbnails]);
|
||||
|
||||
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
||||
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
onClick={() => {
|
||||
//Handle any doc refresh.
|
||||
|
||||
isFunction(refetch) && refetch();
|
||||
|
||||
//Do the imgproxy refresh too
|
||||
fetchThumbnails();
|
||||
}}
|
||||
>
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
|
||||
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} />
|
||||
<JobsDocumentsDeleteButton
|
||||
galleryImages={galleryImages}
|
||||
deletionCallback={billsCallback || fetchThumbnails || refetch}
|
||||
/>
|
||||
{!billId && (
|
||||
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
{!hasMediaAccess && (
|
||||
<Col span={24}>
|
||||
<Card>
|
||||
<UpsellComponent disableMask upsell={upsellEnum().media.general} />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={24}>
|
||||
<Card>
|
||||
<DocumentsUploadImgproxyComponent
|
||||
jobId={jobId}
|
||||
totalSize={totalSize}
|
||||
billId={billId}
|
||||
callbackAfterUpload={billsCallback || fetchThumbnails || refetch}
|
||||
ignoreSizeLimit={ignoreSizeLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{hasMediaAccess && !hasMobileAccess && (
|
||||
<Col span={24}>
|
||||
<Card>
|
||||
<UpsellComponent upsell={upsellEnum().media.mobile} />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={24}>
|
||||
<Card title={t("jobs.labels.documents-images")}>
|
||||
<Gallery
|
||||
images={galleryImages.images}
|
||||
onClick={(index, item) => {
|
||||
setModalState({ open: true, index: index });
|
||||
// window.open(
|
||||
// item.fullsize,
|
||||
// "_blank",
|
||||
// "toolbar=0,location=0,menubar=0"
|
||||
// );
|
||||
}}
|
||||
onSelect={(index, image) => {
|
||||
setGalleryImages({
|
||||
...galleryImages,
|
||||
images: galleryImages.images.map((g, idx) =>
|
||||
index === idx ? { ...g, isSelected: !g.isSelected } : g
|
||||
)
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Card title={t("jobs.labels.documents-other")}>
|
||||
<Gallery
|
||||
images={galleryImages.other}
|
||||
thumbnailStyle={() => {
|
||||
return {
|
||||
backgroundImage: <FileExcelFilled />,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
cursor: "pointer"
|
||||
};
|
||||
}}
|
||||
onClick={(index) => {
|
||||
window.open(galleryImages.other[index].source, "_blank", "toolbar=0,location=0,menubar=0");
|
||||
}}
|
||||
onSelect={(index) => {
|
||||
setGalleryImages({
|
||||
...galleryImages,
|
||||
other: galleryImages.other.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g))
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{modalState.open && (
|
||||
<Lightbox
|
||||
toolbarButtons={[
|
||||
<EditFilled
|
||||
key="edit"
|
||||
onClick={() => {
|
||||
const newWindow = window.open(
|
||||
`${window.location.protocol}//${window.location.host}/edit?documentId=${
|
||||
galleryImages.images[modalState.index].id
|
||||
}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
if (newWindow) newWindow.opener = null;
|
||||
}}
|
||||
/>
|
||||
]}
|
||||
mainSrc={galleryImages.images[modalState.index].fullsize}
|
||||
nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize}
|
||||
prevSrc={
|
||||
galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length]
|
||||
.fullsize
|
||||
}
|
||||
onCloseRequest={() => setModalState({ open: false, index: 0 })}
|
||||
onMovePrevRequest={() =>
|
||||
setModalState({
|
||||
...modalState,
|
||||
index: (modalState.index + galleryImages.images.length - 1) % galleryImages.images.length
|
||||
})
|
||||
}
|
||||
onMoveNextRequest={() =>
|
||||
setModalState({
|
||||
...modalState,
|
||||
index: (modalState.index + 1) % galleryImages.images.length
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyComponent);
|
||||
|
||||
export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, imagesOnly }) => {
|
||||
const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId });
|
||||
const documents = result.data.reduce(
|
||||
(acc, value) => {
|
||||
if (value.type.startsWith("image")) {
|
||||
acc.images.push({
|
||||
src: value.thumbnailUrl,
|
||||
fullsize: value.originalUrl,
|
||||
height: 225,
|
||||
width: 225,
|
||||
isSelected: false,
|
||||
key: value.key,
|
||||
extension: value.extension,
|
||||
id: value.id,
|
||||
type: value.type,
|
||||
size: value.size,
|
||||
tags: [{ value: value.type, title: value.type }]
|
||||
});
|
||||
} else {
|
||||
const fileName = value.key.split("/").pop();
|
||||
acc.other.push({
|
||||
source: value.originalUrlViaProxyPath,
|
||||
src: value.thumbnailUrl,
|
||||
fullsize: value.presignedGetUrl,
|
||||
tags: [
|
||||
{
|
||||
value: fileName,
|
||||
title: fileName
|
||||
},
|
||||
|
||||
{ value: value.type, title: value.type },
|
||||
...(value.bill
|
||||
? [
|
||||
{
|
||||
value: value.bill.vendor.name,
|
||||
title: i18n.t("vendors.fields.name")
|
||||
},
|
||||
{ value: value.bill.date, title: i18n.t("bills.fields.date") },
|
||||
{
|
||||
value: value.bill.invoice_number,
|
||||
title: i18n.t("bills.fields.invoice_number")
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
height: 225,
|
||||
width: 225,
|
||||
isSelected: false,
|
||||
extension: value.extension,
|
||||
key: value.key,
|
||||
id: value.id,
|
||||
type: value.type,
|
||||
size: value.size
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ images: [], other: [] }
|
||||
);
|
||||
|
||||
setStateCallback(imagesOnly ? documents.images : documents);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import JobDocuments from "./jobs-documents-imgproxy-gallery.component";
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
|
||||
################################################################################################
|
||||
*/
|
||||
|
||||
export default function JobsDocumentsImgproxyContainer({ jobId, billId, documentsList, billsCallback }) {
|
||||
const { loading, error, data, refetch } = useQuery(GET_DOCUMENTS_BY_JOB, {
|
||||
variables: { jobId: jobId },
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: !!billId
|
||||
});
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <AlertComponent type="error" message={error.message} />;
|
||||
|
||||
return (
|
||||
<JobDocuments
|
||||
data={(data && data.documents) || documentsList || []}
|
||||
downloadIdentifier={data && data.jobs_by_pk.ro_number}
|
||||
totalSize={data && data.documents_aggregate.aggregate.sum.size}
|
||||
billId={billId}
|
||||
jobId={jobId}
|
||||
refetch={refetch}
|
||||
billsCallback={billsCallback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { QuestionCircleOutlined } from "@ant-design/icons";
|
||||
import { Button, Popconfirm } from "antd";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils.js";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { isFunction } from "lodash";
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
|
||||
################################################################################################
|
||||
*/
|
||||
|
||||
export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, deletionCallback }) {
|
||||
const { t } = useTranslation();
|
||||
const notification = useNotification();
|
||||
|
||||
const imagesToDelete = [
|
||||
...galleryImages.images.filter((image) => image.isSelected),
|
||||
...galleryImages.other.filter((image) => image.isSelected)
|
||||
];
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
logImEXEvent("job_documents_delete", { count: imagesToDelete.length });
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await axios.post("/media/imgproxy/delete", {
|
||||
ids: imagesToDelete.map((d) => d.id)
|
||||
});
|
||||
|
||||
if (res.data.error) {
|
||||
notification.error({
|
||||
message: t("documents.errors.deleting", {
|
||||
error: JSON.stringify(res.data.error.response.errors)
|
||||
})
|
||||
});
|
||||
} else {
|
||||
notification.success({
|
||||
key: "docdeletedsuccesfully",
|
||||
message: t("documents.successes.delete")
|
||||
});
|
||||
|
||||
if (isFunction(deletionCallback)) deletionCallback();
|
||||
}
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: t("documents.errors.deleting", {
|
||||
error: error.message
|
||||
})
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popconfirm
|
||||
disabled={imagesToDelete.length < 1}
|
||||
icon={<QuestionCircleOutlined style={{ color: "red" }} />}
|
||||
onConfirm={handleDelete}
|
||||
title={t("documents.labels.confirmdelete")}
|
||||
okText={t("general.actions.delete")}
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText={t("general.actions.cancel")}
|
||||
>
|
||||
<Button disabled={imagesToDelete.length < 1} loading={loading}>
|
||||
{t("documents.actions.delete")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { Gallery } from "react-grid-gallery";
|
||||
import { fetchImgproxyThumbnails } from "./jobs-documents-imgproxy-gallery.component";
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
|
||||
################################################################################################
|
||||
*/
|
||||
|
||||
function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState }) {
|
||||
const [galleryImages, setgalleryImages] = externalMediaState;
|
||||
|
||||
useEffect(() => {
|
||||
if (jobId) fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true });
|
||||
}, [jobId, setgalleryImages]);
|
||||
|
||||
return (
|
||||
<div className="clearfix">
|
||||
<Gallery
|
||||
images={galleryImages}
|
||||
backdropClosesModal={true}
|
||||
onSelect={(index, image) => {
|
||||
setgalleryImages(galleryImages.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g)));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JobsDocumentImgproxyGalleryExternal;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Button, Space } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/*
|
||||
################################################################################################
|
||||
Developer Note:
|
||||
Known Technical Debt Item
|
||||
Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration.
|
||||
################################################################################################
|
||||
*/
|
||||
|
||||
export default function JobsDocumentsImgproxyGallerySelectAllComponent({ galleryImages, setGalleryImages }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setGalleryImages({
|
||||
...galleryImages,
|
||||
other: galleryImages.other.map((i) => {
|
||||
return { ...i, isSelected: true };
|
||||
}),
|
||||
images: galleryImages.images.map((i) => {
|
||||
return { ...i, isSelected: true };
|
||||
})
|
||||
});
|
||||
};
|
||||
const handleSelectAllImages = () => {
|
||||
setGalleryImages({
|
||||
...galleryImages,
|
||||
|
||||
images: galleryImages.images.map((i) => {
|
||||
return { ...i, isSelected: true };
|
||||
})
|
||||
});
|
||||
};
|
||||
const handleSelectAllDocuments = () => {
|
||||
setGalleryImages({
|
||||
...galleryImages,
|
||||
other: galleryImages.other.map((i) => {
|
||||
return { ...i, isSelected: true };
|
||||
})
|
||||
});
|
||||
};
|
||||
const handleDeselectAll = () => {
|
||||
setGalleryImages({
|
||||
...galleryImages,
|
||||
other: galleryImages.other.map((i) => {
|
||||
return { ...i, isSelected: false };
|
||||
}),
|
||||
images: galleryImages.images.map((i) => {
|
||||
return { ...i, isSelected: false };
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button onClick={handleSelectAll}>{t("general.actions.selectall")}</Button>
|
||||
<Button onClick={handleSelectAllImages}>{t("documents.actions.selectallimages")}</Button>
|
||||
<Button onClick={handleSelectAllDocuments}>{t("documents.actions.selectallotherdocuments")}</Button>
|
||||
<Button onClick={handleDeselectAll}>{t("general.actions.deselectall")}</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* you can make up upload button and sample style by using stylesheets */
|
||||
.ant-upload-select-picture-card i {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card .ant-upload-text {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } 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 DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
import CurrencyInput from "../form-items-formatted/currency-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 VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
||||
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -32,7 +33,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const handleClick = ({ item }) => {
|
||||
const handleClick = ({ item, key, keyPath }) => {
|
||||
form.setFieldsValue({ comments: item.props.value });
|
||||
};
|
||||
|
||||
@@ -97,18 +98,17 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isReturn && (
|
||||
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
|
||||
<Radio.Group disabled={sendType === "oec"}>
|
||||
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
|
||||
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
|
||||
<Radio.Group disabled={sendType === "oec"}>
|
||||
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
|
||||
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<Divider orientation="left">{t("parts_orders.labels.inthisorder")}</Divider>
|
||||
<Form.List name={["parts_order_lines", "data"]}>
|
||||
{(fields, { remove, move }) => {
|
||||
{(fields, { add, remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import React from "react";
|
||||
import AlertComponent from "../../components/alert/alert.component";
|
||||
import JobsDocumentsComponent from "../../components/jobs-documents-gallery/jobs-documents-gallery.component";
|
||||
import JobsDocumentsContainer from "../../components/jobs-documents-gallery/jobs-documents-gallery.container";
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
|
||||
import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries";
|
||||
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import JobsDocumentsLocalGallery from "../../components/jobs-documents-local-gallery/jobs-documents-local-gallery.container";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -19,10 +20,18 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TemporaryDocsComponent);
|
||||
|
||||
export function TemporaryDocsComponent({ bodyshop }) {
|
||||
const {
|
||||
treatments: { Imgproxy }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Imgproxy"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const { loading, error, data, refetch } = useQuery(QUERY_TEMPORARY_DOCS, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: bodyshop.uselocalmediaserver
|
||||
skip: Imgproxy.treatment === "on"
|
||||
});
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
@@ -32,12 +41,14 @@ export function TemporaryDocsComponent({ bodyshop }) {
|
||||
return <JobsDocumentsLocalGallery job={{ id: "temporary" }} />;
|
||||
}
|
||||
return (
|
||||
<JobsDocumentsComponent
|
||||
data={data ? data.documents : []}
|
||||
jobId={null}
|
||||
billId={null}
|
||||
refetch={refetch}
|
||||
ignoreSizeLimit
|
||||
/>
|
||||
<>
|
||||
<JobsDocumentsContainer
|
||||
documentsList={data ? data.documents : []}
|
||||
jobId={null}
|
||||
billId={null}
|
||||
refetchOverride={refetch}
|
||||
ignoreSizeLimit
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
1274
package-lock.json
generated
1274
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,9 +19,12 @@
|
||||
"@aws-sdk/client-secrets-manager": "^3.772.0",
|
||||
"@aws-sdk/client-ses": "^3.772.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.772.0",
|
||||
"@aws-sdk/lib-storage": "^3.743.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.731.1",
|
||||
"@opensearch-project/opensearch": "^2.13.0",
|
||||
"@socket.io/admin-ui": "^0.5.1",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4": "^1.13.2",
|
||||
"axios": "^1.8.4",
|
||||
"bee-queue": "^1.7.1",
|
||||
|
||||
@@ -370,7 +370,7 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||
*/
|
||||
const main = async () => {
|
||||
const app = express();
|
||||
const port = process.env.PORT || 5000;
|
||||
const port = process.env.PORT || 4000;
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
|
||||
@@ -2771,3 +2771,61 @@ exports.GET_BODYSHOP_BY_ID = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.GET_DOCUMENTS_BY_JOB = `
|
||||
query GET_DOCUMENTS_BY_JOB($jobId: uuid!) {
|
||||
jobs_by_pk(id: $jobId) {
|
||||
id
|
||||
ro_number
|
||||
}
|
||||
documents_aggregate(where: { jobid: { _eq: $jobId } }) {
|
||||
aggregate {
|
||||
sum {
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
documents(order_by: { takenat: desc }, where: { jobid: { _eq: $jobId } }) {
|
||||
id
|
||||
name
|
||||
key
|
||||
type
|
||||
size
|
||||
takenat
|
||||
extension
|
||||
bill {
|
||||
id
|
||||
invoice_number
|
||||
date
|
||||
vendor {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.QUERY_TEMPORARY_DOCS = ` query QUERY_TEMPORARY_DOCS {
|
||||
documents(where: { jobid: { _is_null: true } }, order_by: { takenat: desc }) {
|
||||
id
|
||||
name
|
||||
key
|
||||
type
|
||||
extension
|
||||
size
|
||||
takenat
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.GET_DOCUMENTS_BY_IDS = `
|
||||
query GET_DOCUMENTS_BY_IDS($documentIds: [uuid!]!) {
|
||||
documents(where: {id: {_in: $documentIds}}, order_by: {takenat: desc}) {
|
||||
id
|
||||
name
|
||||
key
|
||||
type
|
||||
extension
|
||||
size
|
||||
takenat
|
||||
}
|
||||
}`;
|
||||
|
||||
348
server/media/imgproxy-media.js
Normal file
348
server/media/imgproxy-media.js
Normal file
@@ -0,0 +1,348 @@
|
||||
const path = require("path");
|
||||
require("dotenv").config({
|
||||
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
|
||||
});
|
||||
const logger = require("../utils/logger");
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
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) => {
|
||||
const { filenames, bodyshopid, jobid } = req.body;
|
||||
try {
|
||||
logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, { filenames, bodyshopid, jobid });
|
||||
|
||||
const signedUrls = [];
|
||||
for (const filename of filenames) {
|
||||
const key = filename;
|
||||
const client = new S3Client({ region: InstanceRegion() });
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
Key: key,
|
||||
StorageClass: "INTELLIGENT_TIERING"
|
||||
});
|
||||
const presignedUrl = await getSignedUrl(client, command, { expiresIn: 360 });
|
||||
signedUrls.push({ filename, presignedUrl, key });
|
||||
}
|
||||
|
||||
logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls });
|
||||
res.json({
|
||||
success: true,
|
||||
signedUrls
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.getThumbnailUrls = async (req, res) => {
|
||||
const { jobid, billid } = req.body;
|
||||
|
||||
try {
|
||||
logger.log("imgproxy-thumbnails", "DEBUG", req.user?.email, jobid, { billid, jobid });
|
||||
|
||||
//Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components.
|
||||
const client = req.userGraphQLClient;
|
||||
//If there's no jobid and no billid, we're in temporary documents.
|
||||
const data = await (jobid
|
||||
? client.request(GET_DOCUMENTS_BY_JOB, { jobId: jobid })
|
||||
: client.request(QUERY_TEMPORARY_DOCS));
|
||||
|
||||
const thumbResizeParams = `rs:fill:250:250:1/g:ce`;
|
||||
const s3client = new S3Client({ region: InstanceRegion() });
|
||||
const proxiedUrls = [];
|
||||
|
||||
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>
|
||||
|
||||
//When working with documents from Cloudinary, the URL does not include the extension.
|
||||
let key;
|
||||
if (/\.[^/.]+$/.test(document.key)) {
|
||||
key = document.key;
|
||||
} else {
|
||||
key = `${document.key}.${document.extension.toLowerCase()}`;
|
||||
}
|
||||
// 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}`);
|
||||
|
||||
const s3Props = {};
|
||||
if (!document.type.startsWith("image")) {
|
||||
//If not a picture, we need to get a signed download link to the file using S3 (or cloudfront preferably)
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
Key: key
|
||||
});
|
||||
const presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 });
|
||||
s3Props.presignedGetUrl = presignedGetUrl;
|
||||
|
||||
const originalProxyPath = `raw:1/${base64UrlEncodedKeyString}`;
|
||||
const originalHmacSalt = createHmacSha256(`${imgproxySalt}/${originalProxyPath}`);
|
||||
s3Props.originalUrlViaProxyPath = `${imgproxyBaseUrl}/${originalHmacSalt}/${originalProxyPath}`;
|
||||
}
|
||||
|
||||
proxiedUrls.push({
|
||||
originalUrl: `${imgproxyBaseUrl}/${fullSizeHmacSalt}/${fullSizeProxyPath}`,
|
||||
thumbnailUrl: `${imgproxyBaseUrl}/${thumbHmacSalt}/${thumbProxyPath}`,
|
||||
fullS3Path,
|
||||
base64UrlEncodedKeyString,
|
||||
thumbProxyPath,
|
||||
...s3Props,
|
||||
...document
|
||||
});
|
||||
}
|
||||
|
||||
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, {
|
||||
jobid,
|
||||
billid,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
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) => {
|
||||
//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;
|
||||
try {
|
||||
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.
|
||||
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);
|
||||
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));
|
||||
archiveStream.append(response.Body, { name: path.basename(key) });
|
||||
}
|
||||
|
||||
archiveStream.finalize();
|
||||
|
||||
const archiveKey = `archives/${jobid}/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 }
|
||||
});
|
||||
|
||||
parallelUploads3.on("httpUploadProgress", (progress) => {
|
||||
console.log(progress);
|
||||
});
|
||||
|
||||
const uploadResult = await parallelUploads3.done();
|
||||
//Generate the presigned URL to download it.
|
||||
const presignedUrl = await getSignedUrl(
|
||||
s3client,
|
||||
new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: archiveKey }),
|
||||
{ expiresIn: 360 }
|
||||
);
|
||||
|
||||
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,
|
||||
billid,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
res.status(400).json({ message: error.message, stack: error.stack });
|
||||
}
|
||||
};
|
||||
|
||||
exports.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;
|
||||
try {
|
||||
logger.log("imgproxy-delete-files", "DEBUG", req.user.email, null, { ids });
|
||||
const client = req.userGraphQLClient;
|
||||
|
||||
//Do this to make sure that they are only deleting things that they have access to
|
||||
const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: ids });
|
||||
|
||||
const s3client = new S3Client({ region: InstanceRegion() });
|
||||
|
||||
const deleteTransactions = [];
|
||||
data.documents.forEach((document) => {
|
||||
deleteTransactions.push(
|
||||
(async () => {
|
||||
try {
|
||||
// Delete the original object
|
||||
const deleteResult = await s3client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
Key: document.key
|
||||
})
|
||||
);
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
return { document, error: error, bucket: imgproxyDestinationBucket };
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
const result = await Promise.all(deleteTransactions);
|
||||
const errors = result.filter((d) => d.error);
|
||||
|
||||
//Delete only the succesful deletes.
|
||||
const deleteMutationResult = await client.request(DELETE_MEDIA_DOCUMENTS, {
|
||||
ids: result.filter((t) => !t.error).map((d) => d.id)
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
exports.moveFiles = async (req, res) => {
|
||||
const { documents, tojobid } = req.body;
|
||||
try {
|
||||
logger.log("imgproxy-move-files", "DEBUG", req.user.email, null, { documents, tojobid });
|
||||
const s3client = new S3Client({ region: InstanceRegion() });
|
||||
|
||||
const moveTransactions = [];
|
||||
documents.forEach((document) => {
|
||||
moveTransactions.push(
|
||||
(async () => {
|
||||
try {
|
||||
// Copy the object to the new key
|
||||
const copyresult = await s3client.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
CopySource: `${imgproxyDestinationBucket}/${document.from}`,
|
||||
Key: document.to,
|
||||
StorageClass: "INTELLIGENT_TIERING"
|
||||
})
|
||||
);
|
||||
|
||||
// Delete the original object
|
||||
const deleteResult = await s3client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: imgproxyDestinationBucket,
|
||||
Key: document.from
|
||||
})
|
||||
);
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
return { id: document.id, from: document.from, error: error, bucket: imgproxyDestinationBucket };
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
const result = await Promise.all(moveTransactions);
|
||||
const errors = result.filter((d) => d.error);
|
||||
|
||||
let mutations = "";
|
||||
result
|
||||
.filter((d) => !d.error)
|
||||
.forEach((d, idx) => {
|
||||
//Create mutation text
|
||||
mutations =
|
||||
mutations +
|
||||
`
|
||||
update_doc${idx}:update_documents_by_pk(pk_columns: { id: "${d.id}" }, _set: {key: "${d.to}", jobid: "${tojobid}"}){
|
||||
id
|
||||
}
|
||||
`;
|
||||
});
|
||||
|
||||
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. " });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, {
|
||||
documents,
|
||||
tojobid,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
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");
|
||||
}
|
||||
@@ -11,12 +11,14 @@ require("dotenv").config({
|
||||
var cloudinary = require("cloudinary").v2;
|
||||
cloudinary.config(process.env.CLOUDINARY_URL);
|
||||
|
||||
exports.createSignedUploadURL = (req, 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.downloadFiles = (req, res) => {
|
||||
exports.createSignedUploadURL = createSignedUploadURL;
|
||||
|
||||
const downloadFiles = (req, res) => {
|
||||
const { ids } = req.body;
|
||||
logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null);
|
||||
|
||||
@@ -26,8 +28,9 @@ exports.downloadFiles = (req, res) => {
|
||||
});
|
||||
res.send(url);
|
||||
};
|
||||
exports.downloadFiles = downloadFiles;
|
||||
|
||||
exports.deleteFiles = async (req, res) => {
|
||||
const deleteFiles = async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
const types = _.groupBy(ids, (x) => DetermineFileType(x.type));
|
||||
|
||||
@@ -88,7 +91,9 @@ exports.deleteFiles = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.renameKeys = async (req, res) => {
|
||||
exports.deleteFiles = deleteFiles;
|
||||
|
||||
const renameKeys = async (req, res) => {
|
||||
const { documents, tojobid } = req.body;
|
||||
logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents);
|
||||
|
||||
@@ -146,6 +151,7 @@ exports.renameKeys = async (req, res) => {
|
||||
res.json({ errors: "No images were succesfully moved on remote server. " });
|
||||
}
|
||||
};
|
||||
exports.renameKeys = renameKeys;
|
||||
|
||||
//Also needs to be updated in upload utility and mobile app.
|
||||
function DetermineFileType(filetype) {
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = require("../media/media");
|
||||
const {
|
||||
generateSignedUploadUrls: createSignedUploadURLImgproxy,
|
||||
getThumbnailUrls: getThumbnailUrlsImgproxy,
|
||||
downloadFiles: downloadFilesImgproxy,
|
||||
moveFiles: moveFilesImgproxy,
|
||||
deleteFiles: deleteFilesImgproxy
|
||||
} = require("../media/imgproxy-media");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
|
||||
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
router.use(withUserGraphQLClientMiddleware);
|
||||
|
||||
router.post("/sign", createSignedUploadURL);
|
||||
router.post("/download", downloadFiles);
|
||||
router.post("/rename", renameKeys);
|
||||
router.post("/delete", deleteFiles);
|
||||
|
||||
router.post("/imgproxy/sign", createSignedUploadURLImgproxy);
|
||||
router.post("/imgproxy/thumbnails", getThumbnailUrlsImgproxy);
|
||||
router.post("/imgproxy/download", downloadFilesImgproxy);
|
||||
router.post("/imgproxy/rename", moveFilesImgproxy);
|
||||
router.post("/imgproxy/delete", deleteFilesImgproxy);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user