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 { PictureFilled } from "@ant-design/icons";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
import { Badge, Popover } from "antd";
|
import { Badge, Popover } from "antd";
|
||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -9,6 +10,7 @@ import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
|
|||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.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 JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.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 }) {
|
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
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, {
|
const { loading, error, data } = useQuery(GET_DOCUMENTS_BY_JOB, {
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
@@ -42,6 +51,10 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
|||||||
setSelectedMedia([]);
|
setSelectedMedia([]);
|
||||||
}, [setSelectedMedia, conversation]);
|
}, [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 = (
|
const content = (
|
||||||
<div>
|
<div>
|
||||||
{loading && <LoadingSpinner />}
|
{loading && <LoadingSpinner />}
|
||||||
@@ -49,17 +62,37 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
|||||||
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
|
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
|
||||||
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div>
|
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!bodyshop.uselocalmediaserver && data && (
|
|
||||||
<JobDocumentsGalleryExternal
|
{Imgproxy.treatment === "on" ? (
|
||||||
data={data ? data.documents : []}
|
<>
|
||||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
{!bodyshop.uselocalmediaserver && (
|
||||||
/>
|
<JobsDocumentImgproxyGalleryExternal
|
||||||
)}
|
jobId={conversation.job_conversations[0].jobid}
|
||||||
{bodyshop.uselocalmediaserver && open && (
|
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||||
<JobDocumentsLocalGalleryExternal
|
/>
|
||||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
)}
|
||||||
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
|
{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>
|
</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 JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
|
||||||
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-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 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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
//currentUser: selectCurrentUser
|
//currentUser: selectCurrentUser
|
||||||
@@ -23,6 +25,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(EmailDocumentsCompon
|
|||||||
|
|
||||||
export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState, bodyshop }) {
|
export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState, bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
treatments: { Imgproxy }
|
||||||
|
} = useSplitTreatments({
|
||||||
|
attributes: {},
|
||||||
|
names: ["Imgproxy"],
|
||||||
|
splitKey: bodyshop && bodyshop.imexshopid
|
||||||
|
});
|
||||||
|
|
||||||
const [selectedMedia, setSelectedMedia] = selectedMediaState;
|
const [selectedMedia, setSelectedMedia] = selectedMediaState;
|
||||||
const { loading, error, data } = useQuery(GET_DOCUMENTS_BY_JOB, {
|
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 ? (
|
10485760 - new Blob([form.getFieldValue("html")]).size ? (
|
||||||
<div style={{ color: "red" }}>{t("general.errors.sizelimit")}</div>
|
<div style={{ color: "red" }}>{t("general.errors.sizelimit")}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!bodyshop.uselocalmediaserver && data && (
|
|
||||||
<JobDocumentsGalleryExternal
|
{Imgproxy.treatment === "on" ? (
|
||||||
data={data ? data.documents : []}
|
<>
|
||||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
{!bodyshop.uselocalmediaserver && data && (
|
||||||
/>
|
<JobsDocumentImgproxyGalleryExternal
|
||||||
)}
|
jobId={emailConfig.jobid}
|
||||||
{bodyshop.uselocalmediaserver && (
|
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||||
<JobsDocumentsLocalGalleryExternalComponent
|
/>
|
||||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
)}
|
||||||
jobId={emailConfig.jobid}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsDownloadButton);
|
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 }) {
|
export function JobsDocumentsDownloadButton({ bodyshop, galleryImages, identifier }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [download, setDownload] = useState(null);
|
const [download, setDownload] = useState(null);
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsGalleryReassign);
|
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 }) {
|
export function JobsDocumentsGalleryReassign({ bodyshop, galleryImages, callback }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|||||||
@@ -1,29 +1,34 @@
|
|||||||
import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
|
import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons";
|
||||||
import { Button, Card, Col, Row, Space } from "antd";
|
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 { Gallery } from "react-grid-gallery";
|
||||||
import { useTranslation } from "react-i18next";
|
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 DocumentsUploadComponent from "../documents-upload/documents-upload.component";
|
||||||
import { DetermineFileType } from "../documents-upload/documents-upload.utility";
|
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 { GenerateSrcUrl, GenerateThumbUrl } from "./job-documents.utility";
|
||||||
import JobsDocumentsDownloadButton from "./jobs-document-gallery.download.component";
|
import JobsDocumentsDownloadButton from "./jobs-document-gallery.download.component";
|
||||||
import JobsDocumentsGalleryReassign from "./jobs-document-gallery.reassign.component";
|
import JobsDocumentsGalleryReassign from "./jobs-document-gallery.reassign.component";
|
||||||
import JobsDocumentsDeleteButton from "./jobs-documents-gallery.delete.component";
|
import JobsDocumentsDeleteButton from "./jobs-documents-gallery.delete.component";
|
||||||
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-gallery.selectall.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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = (dispatch) => ({});
|
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({
|
function JobsDocumentsComponent({
|
||||||
bodyshop,
|
bodyshop,
|
||||||
data,
|
data,
|
||||||
@@ -114,6 +119,7 @@ function JobsDocumentsComponent({
|
|||||||
);
|
);
|
||||||
setgalleryImages(documents);
|
setgalleryImages(documents);
|
||||||
}, [data, setgalleryImages, t]);
|
}, [data, setgalleryImages, t]);
|
||||||
|
|
||||||
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
||||||
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
|
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
|
||||||
return (
|
return (
|
||||||
@@ -137,7 +143,6 @@ function JobsDocumentsComponent({
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card>
|
<Card>
|
||||||
<DocumentsUploadComponent
|
<DocumentsUploadComponent
|
||||||
|
|||||||
@@ -2,29 +2,77 @@ import { useQuery } from "@apollo/client";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
|
import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
|
||||||
import AlertComponent from "../alert/alert.component";
|
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 LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
import JobDocuments from "./jobs-documents-gallery.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, {
|
const { loading, error, data, refetch } = useQuery(GET_DOCUMENTS_BY_JOB, {
|
||||||
variables: { jobId: jobId },
|
variables: { jobId: jobId },
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only",
|
nextFetchPolicy: "network-only",
|
||||||
skip: !!billId
|
skip: Imgproxy.treatment === "on" || !!billId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) return <LoadingSpinner />;
|
if (loading) return <LoadingSpinner />;
|
||||||
if (error) return <AlertComponent type="error" message={error.message} />;
|
if (error) return <AlertComponent type="error" message={error.message} />;
|
||||||
|
|
||||||
return (
|
if (Imgproxy.treatment === "on") {
|
||||||
<JobDocuments
|
return (
|
||||||
data={(data && data.documents) || documentsList || []}
|
<JobDocumentsImgProxy
|
||||||
downloadIdentifier={data && data.jobs_by_pk.ro_number}
|
data={(data && data.documents) || documentsList || []}
|
||||||
totalSize={data && data.documents_aggregate.aggregate.sum.size}
|
downloadIdentifier={data && data.jobs_by_pk.ro_number}
|
||||||
billId={billId}
|
totalSize={data && data.documents_aggregate.aggregate.sum.size}
|
||||||
jobId={jobId}
|
billId={billId}
|
||||||
refetch={refetch}
|
jobId={jobId}
|
||||||
billsCallback={billsCallback}
|
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 { useTranslation } from "react-i18next";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
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 }) {
|
export default function JobsDocumentsDeleteButton({ galleryImages, deletionCallback }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { Gallery } from "react-grid-gallery";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GenerateSrcUrl, GenerateThumbUrl } from "./job-documents.utility";
|
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({
|
function JobsDocumentGalleryExternal({
|
||||||
data,
|
data,
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { Button, Space } from "antd";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 }) {
|
export default function JobsDocumentsGallerySelectAllComponent({ galleryImages, setGalleryImages }) {
|
||||||
const { t } = useTranslation();
|
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 { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
||||||
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
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 CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
||||||
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -32,7 +33,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleClick = ({ item }) => {
|
const handleClick = ({ item, key, keyPath }) => {
|
||||||
form.setFieldsValue({ comments: item.props.value });
|
form.setFieldsValue({ comments: item.props.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,18 +98,17 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
|
|||||||
<Checkbox />
|
<Checkbox />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{!isReturn && (
|
|
||||||
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
|
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
|
||||||
<Radio.Group disabled={sendType === "oec"}>
|
<Radio.Group disabled={sendType === "oec"}>
|
||||||
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
|
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
|
||||||
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
|
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
<Divider orientation="left">{t("parts_orders.labels.inthisorder")}</Divider>
|
<Divider orientation="left">{t("parts_orders.labels.inthisorder")}</Divider>
|
||||||
<Form.List name={["parts_order_lines", "data"]}>
|
<Form.List name={["parts_order_lines", "data"]}>
|
||||||
{(fields, { remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import AlertComponent from "../../components/alert/alert.component";
|
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 LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
|
||||||
import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries";
|
import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries";
|
||||||
|
|
||||||
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
|
||||||
import JobsDocumentsLocalGallery from "../../components/jobs-documents-local-gallery/jobs-documents-local-gallery.container";
|
import JobsDocumentsLocalGallery from "../../components/jobs-documents-local-gallery/jobs-documents-local-gallery.container";
|
||||||
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -19,10 +20,18 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
export default connect(mapStateToProps, mapDispatchToProps)(TemporaryDocsComponent);
|
export default connect(mapStateToProps, mapDispatchToProps)(TemporaryDocsComponent);
|
||||||
|
|
||||||
export function TemporaryDocsComponent({ bodyshop }) {
|
export function TemporaryDocsComponent({ bodyshop }) {
|
||||||
|
const {
|
||||||
|
treatments: { Imgproxy }
|
||||||
|
} = useSplitTreatments({
|
||||||
|
attributes: {},
|
||||||
|
names: ["Imgproxy"],
|
||||||
|
splitKey: bodyshop && bodyshop.imexshopid
|
||||||
|
});
|
||||||
|
|
||||||
const { loading, error, data, refetch } = useQuery(QUERY_TEMPORARY_DOCS, {
|
const { loading, error, data, refetch } = useQuery(QUERY_TEMPORARY_DOCS, {
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only",
|
nextFetchPolicy: "network-only",
|
||||||
skip: bodyshop.uselocalmediaserver
|
skip: Imgproxy.treatment === "on"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) return <LoadingSpinner />;
|
if (loading) return <LoadingSpinner />;
|
||||||
@@ -32,12 +41,14 @@ export function TemporaryDocsComponent({ bodyshop }) {
|
|||||||
return <JobsDocumentsLocalGallery job={{ id: "temporary" }} />;
|
return <JobsDocumentsLocalGallery job={{ id: "temporary" }} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<JobsDocumentsComponent
|
<>
|
||||||
data={data ? data.documents : []}
|
<JobsDocumentsContainer
|
||||||
jobId={null}
|
documentsList={data ? data.documents : []}
|
||||||
billId={null}
|
jobId={null}
|
||||||
refetch={refetch}
|
billId={null}
|
||||||
ignoreSizeLimit
|
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-secrets-manager": "^3.772.0",
|
||||||
"@aws-sdk/client-ses": "^3.772.0",
|
"@aws-sdk/client-ses": "^3.772.0",
|
||||||
"@aws-sdk/credential-provider-node": "^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",
|
"@opensearch-project/opensearch": "^2.13.0",
|
||||||
"@socket.io/admin-ui": "^0.5.1",
|
"@socket.io/admin-ui": "^0.5.1",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"aws4": "^1.13.2",
|
"aws4": "^1.13.2",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"bee-queue": "^1.7.1",
|
"bee-queue": "^1.7.1",
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
|||||||
*/
|
*/
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 5000;
|
const port = process.env.PORT || 4000;
|
||||||
|
|
||||||
const server = http.createServer(app);
|
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;
|
var cloudinary = require("cloudinary").v2;
|
||||||
cloudinary.config(process.env.CLOUDINARY_URL);
|
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);
|
logger.log("media-signed-upload", "DEBUG", req.user.email, null, null);
|
||||||
res.send(cloudinary.utils.api_sign_request(req.body, process.env.CLOUDINARY_API_SECRET));
|
res.send(cloudinary.utils.api_sign_request(req.body, process.env.CLOUDINARY_API_SECRET));
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.downloadFiles = (req, res) => {
|
exports.createSignedUploadURL = createSignedUploadURL;
|
||||||
|
|
||||||
|
const downloadFiles = (req, res) => {
|
||||||
const { ids } = req.body;
|
const { ids } = req.body;
|
||||||
logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null);
|
logger.log("media-bulk-download", "DEBUG", req.user.email, ids, null);
|
||||||
|
|
||||||
@@ -26,8 +28,9 @@ exports.downloadFiles = (req, res) => {
|
|||||||
});
|
});
|
||||||
res.send(url);
|
res.send(url);
|
||||||
};
|
};
|
||||||
|
exports.downloadFiles = downloadFiles;
|
||||||
|
|
||||||
exports.deleteFiles = async (req, res) => {
|
const deleteFiles = async (req, res) => {
|
||||||
const { ids } = req.body;
|
const { ids } = req.body;
|
||||||
const types = _.groupBy(ids, (x) => DetermineFileType(x.type));
|
const types = _.groupBy(ids, (x) => DetermineFileType(x.type));
|
||||||
|
|
||||||
@@ -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;
|
const { documents, tojobid } = req.body;
|
||||||
logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents);
|
logger.log("media-bulk-rename", "DEBUG", req.user.email, null, documents);
|
||||||
|
|
||||||
@@ -146,6 +151,7 @@ exports.renameKeys = async (req, res) => {
|
|||||||
res.json({ errors: "No images were succesfully moved on remote server. " });
|
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.
|
//Also needs to be updated in upload utility and mobile app.
|
||||||
function DetermineFileType(filetype) {
|
function DetermineFileType(filetype) {
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = require("../media/media");
|
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 validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||||
|
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
|
||||||
|
|
||||||
router.use(validateFirebaseIdTokenMiddleware);
|
router.use(validateFirebaseIdTokenMiddleware);
|
||||||
|
router.use(withUserGraphQLClientMiddleware);
|
||||||
|
|
||||||
router.post("/sign", createSignedUploadURL);
|
router.post("/sign", createSignedUploadURL);
|
||||||
router.post("/download", downloadFiles);
|
router.post("/download", downloadFiles);
|
||||||
router.post("/rename", renameKeys);
|
router.post("/rename", renameKeys);
|
||||||
router.post("/delete", deleteFiles);
|
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;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user