diff --git a/backend/src/components/supportingDocumentUpload.js b/backend/src/components/supportingDocumentUpload.js index 1ea7b8f0..e441ca9a 100644 --- a/backend/src/components/supportingDocumentUpload.js +++ b/backend/src/components/supportingDocumentUpload.js @@ -1,8 +1,8 @@ 'use strict'; -const {postApplicationDocument, getApplicationDocument, deleteDocument, patchOperationWithObjectId} = require('./utils'); +const { postApplicationDocument, getApplicationDocument, patchOperationWithObjectId } = require('./utils'); const HttpStatus = require('http-status-codes'); const log = require('./logger'); -const {getFileExtension, convertHeicDocumentToJpg} = require('../util/uploadFileUtils'); +const { getFileExtension, convertHeicDocumentToJpg } = require('../util/uploadFileUtils'); async function saveDocument(req, res) { try { @@ -11,7 +11,7 @@ async function saveDocument(req, res) { let documentClone = document; let changeRequestNewFacilityId = documentClone.changeRequestNewFacilityId; delete documentClone.changeRequestNewFacilityId; - if (getFileExtension(documentClone.filename) === 'heic' ) { + if (getFileExtension(documentClone.filename) === 'heic') { log.verbose(`saveDocument :: heic detected for file name ${documentClone.filename} starting conversion`); documentClone = await convertHeicDocumentToJpg(documentClone); } @@ -19,12 +19,13 @@ async function saveDocument(req, res) { //if this is a new facility change request, link supporting documents to the New Facility Change Action if (changeRequestNewFacilityId) { await patchOperationWithObjectId('ccof_change_request_new_facilities', changeRequestNewFacilityId, { - 'ccof_Attachments@odata.bind': `/ccof_application_facility_documents(${response?.applicationFacilityDocumentId})` + 'ccof_Attachments@odata.bind': `/ccof_application_facility_documents(${response?.applicationFacilityDocumentId})`, }); } } return res.sendStatus(HttpStatus.OK); } catch (e) { + log.error(e); return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json(e.data ? e.data : e?.status); } } @@ -39,7 +40,6 @@ function mapDocument(fileInfo) { document.ccof_application_facility_documentId = fileInfo['ApplicationFacilityDocument.ccof_application_facility_documentid']; document.description = fileInfo.notetext; return document; - } async function getUploadedDocuments(req, res) { @@ -49,61 +49,23 @@ async function getUploadedDocuments(req, res) { let documentFiles = []; if (response?.value?.length > 0) { for (let fileInfo of response?.value) { - if(getAllFiles){ + if (getAllFiles) { documentFiles.push(mapDocument(fileInfo)); - } - else{ - if(fileInfo.subject !== 'Facility License') { + } else { + if (fileInfo.subject !== 'Facility License') { documentFiles.push(mapDocument(fileInfo)); } } - } } return res.status(HttpStatus.OK).json(documentFiles); } catch (e) { + log.error(e); return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json(e.data ? e.data : e?.status); } } -async function getAllUploadedDocuments(req, res) { - try { - let response = await getApplicationDocument(req.params.applicationId); - let documentFiles = []; - if (response?.value?.length > 0) { - for (let fileInfo of response?.value) { - const document = {}; - document.filename = fileInfo.filename; - document.annotationid = fileInfo.annotationid; - document.documentType = fileInfo.subject; - document.ccof_facility = fileInfo['ApplicationFacilityDocument.ccof_facility']; - document.ccof_facility_name = fileInfo['ApplicationFacilityDocument.ccof_facility@OData.Community.Display.V1.FormattedValue']; - document.ccof_application_facility_documentId = fileInfo['ApplicationFacilityDocument.ccof_application_facility_documentid']; - document.description=fileInfo.notetext; - documentFiles.push(document); - } - } - return res.status(HttpStatus.OK).json(documentFiles); - } catch (e) { - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json(e.data ? e.data : e?.status); - } -} - -async function deleteUploadedDocuments(req, res) { - try { - let deletedDocuments = req.body; - for (let annotationid of deletedDocuments) { - await deleteDocument(annotationid); - } - return res.sendStatus(HttpStatus.OK); - } catch (e) { - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json(e.data ? e.data : e?.status); - } - -} - module.exports = { saveDocument, getUploadedDocuments, - deleteUploadedDocuments }; diff --git a/backend/src/routes/supportingDocuments.js b/backend/src/routes/supportingDocuments.js index 0f2abdc2..d4c4b787 100644 --- a/backend/src/routes/supportingDocuments.js +++ b/backend/src/routes/supportingDocuments.js @@ -3,13 +3,10 @@ const passport = require('passport'); const router = express.Router(); const auth = require('../components/auth'); const isValidBackendToken = auth.isValidBackendToken(); -const {saveDocument, getUploadedDocuments, deleteUploadedDocuments} = require('../components/supportingDocumentUpload'); +const { saveDocument, getUploadedDocuments } = require('../components/supportingDocumentUpload'); module.exports = router; -router.post('', passport.authenticate('jwt', {session: false}), isValidBackendToken, saveDocument); - -router.get('/:applicationId', passport.authenticate('jwt', {session: false}), isValidBackendToken, getUploadedDocuments); - -router.delete('', passport.authenticate('jwt', {session: false}), isValidBackendToken, deleteUploadedDocuments); +router.post('', passport.authenticate('jwt', { session: false }), isValidBackendToken, saveDocument); +router.get('/:applicationId', passport.authenticate('jwt', { session: false }), isValidBackendToken, getUploadedDocuments); diff --git a/backend/src/util/mapping/Mappings.js b/backend/src/util/mapping/Mappings.js index a73b4f14..25e7a41f 100644 --- a/backend/src/util/mapping/Mappings.js +++ b/backend/src/util/mapping/Mappings.js @@ -485,7 +485,11 @@ const DocumentsMappings = [ { back: 'subject', front: 'documentType' }, ]; -const ApplicationDocumentsMappings = [...DocumentsMappings, { back: 'ApplicationFacilityDocument.ccof_facility', front: 'facilityId' }]; +const ApplicationDocumentsMappings = [ + ...DocumentsMappings, + { back: 'ApplicationFacilityDocument.ccof_facility', front: 'facilityId' }, + { back: 'ApplicationFacilityDocument.ccof_facility@OData.Community.Display.V1.FormattedValue', front: 'facilityName' }, +]; module.exports = { ApplicationDocumentsMappings, diff --git a/frontend/src/components/RFI/RFIDocumentUpload.vue b/frontend/src/components/RFI/RFIDocumentUpload.vue index 37a59313..2c7e23e0 100644 --- a/frontend/src/components/RFI/RFIDocumentUpload.vue +++ b/frontend/src/components/RFI/RFIDocumentUpload.vue @@ -9,8 +9,7 @@ Upload supporting documents (for example, receipts, quotes, invoices, and/or budget/finance documents) - The maximum file size is 2MB for each document. Accepted file types are jpg, jpeg, heic, png, pdf, docx, doc, - xls, and xlsx. + {{ FILE_REQUIREMENTS_TEXT }}
@@ -27,12 +26,16 @@ > @@ -82,18 +84,21 @@ @@ -178,8 +147,12 @@ export default { background-color: #f2f2f2; } ->>> ::placeholder { - color: #ff5252 !important; - opacity: 1; +:deep(::placeholder) { + color: red !important; + opacity: 1 !important; +} + +:deep(.v-field__input) { + padding-left: 0px; } diff --git a/frontend/src/components/util/AppDocumentUpload.vue b/frontend/src/components/util/AppDocumentUpload.vue index 5d6aac87..66f77bdb 100644 --- a/frontend/src/components/util/AppDocumentUpload.vue +++ b/frontend/src/components/util/AppDocumentUpload.vue @@ -6,8 +6,7 @@ (Required)
- The maximum file size is 2MB for each document. Accepted file types are jpg, jpeg, heic, png, pdf, docx, doc, - xls, and xlsx. + {{ FILE_REQUIREMENTS_TEXT }}
@@ -28,8 +27,8 @@ v-model="item.file" label="Select a file" prepend-icon="mdi-file-upload" - :rules="[...fileRules]" - :accept="fileExtensionAccept" + :rules="rules.fileRules" + :accept="FILE_TYPES_ACCEPT" :disabled="loading" @update:model-value="validateFile(item.id)" /> @@ -76,8 +75,9 @@ import { uuid } from 'vue-uuid'; import AppButton from '@/components/guiComponents/AppButton.vue'; import alertMixin from '@/mixins/alertMixin.js'; -import { DOCUMENT_TYPES } from '@/utils/constants'; -import { humanFileSize, getFileExtensionWithDot, getFileNameWithMaxNameLength } from '@/utils/file'; +import rules from '@/utils/rules.js'; +import { DOCUMENT_TYPES, FILE_REQUIREMENTS_TEXT, FILE_TYPES_ACCEPT } from '@/utils/constants'; +import { isValidFile, readFile } from '@/utils/file'; export default { components: { AppButton }, @@ -127,7 +127,11 @@ export default { this.documents ?.filter((document) => document.isValidFile && document.file) ?.map(async (item) => { - const convertedFile = await this.readFile(item.file); + const convertedFile = await readFile(item.file); + // XXX (vietle-cgi) - We need this part because the field names differ between the legacy code and AFS, and I want to avoid making too many changes to the legacy code. + convertedFile.fileName = convertedFile.filename; + convertedFile.fileSize = convertedFile.filesize; + convertedFile.documentBody = convertedFile.documentbody; return { ...item, ...convertedFile }; }), ); @@ -144,27 +148,9 @@ export default { }, }, created() { - this.MAX_FILE_SIZE = 2100000; // 2.18 MB is max size since after base64 encoding it might grow upto 3 MB. - this.fileExtensionAccept = ['.pdf', '.png', '.jpg', '.jpeg', '.heic', '.doc', '.docx', '.xls', '.xlsx']; - this.fileFormats = 'PDF, JPEG, JPG, PNG, HEIC, DOC, DOCX, XLS, and XLSX'; - this.fileRules = [ - (value) => { - return ( - !value || - !value.length || - value[0].size < this.MAX_FILE_SIZE || - `The maximum file size is ${humanFileSize(this.MAX_FILE_SIZE)} for each document.` - ); - }, - (value) => { - return ( - !value || - !value.length || - this.fileExtensionAccept.includes(getFileExtensionWithDot(value[0].name)?.toLowerCase()) || - `Accepted file types are ${this.fileFormats}.` - ); - }, - ]; + this.FILE_REQUIREMENTS_TEXT = FILE_REQUIREMENTS_TEXT; + this.FILE_TYPES_ACCEPT = FILE_TYPES_ACCEPT; + this.rules = rules; this.headersUploadedDocuments = [ { title: 'File Name', key: 'fileName', width: '34%' }, { title: 'Description', key: 'description', width: '60%' }, @@ -198,48 +184,13 @@ export default { validateFile(updatedItemId) { const document = this.documents.find((item) => item.id === updatedItemId); - const file = document?.file; - if (file) { - const isLessThanMaxSize = file.size < this.MAX_FILE_SIZE; - const isFileExtensionAccepted = this.fileExtensionAccept.includes( - getFileExtensionWithDot(file.name)?.toLowerCase(), - ); - document.isValidFile = isLessThanMaxSize && isFileExtensionAccepted; - } else { - document.isValidFile = true; - } + document.isValidFile = isValidFile(document?.file); }, resetDocuments() { this.documents = []; }, - readFile(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsArrayBuffer(file); - reader.onload = () => { - const arrayBuffer = reader.result; - const binaryString = new Uint8Array(arrayBuffer).reduce((acc, byte) => acc + String.fromCharCode(byte), ''); - const base64String = window.btoa(binaryString); // Convert to Base64 - const doc = { - fileName: getFileNameWithMaxNameLength(file.name), - fileSize: file.size, - documentBody: base64String, - }; - resolve(doc); - }; - reader.onabort = () => { - this.setErrorAlert('Sorry, an unexpected error seems to have occurred. Try uploading your files later.'); - reject(); - }; - reader.onerror = () => { - this.setErrorAlert('Sorry, an unexpected error seems to have occurred. Try uploading your files later.'); - reject(); - }; - }); - }, - isDeletable(document) { return !this.loading && !this.readonly && document?.documentType !== DOCUMENT_TYPES.APPLICATION_AFS_SUBMITTED; }, diff --git a/frontend/src/store/application.js b/frontend/src/store/application.js index 880ec135..05cae96f 100644 --- a/frontend/src/store/application.js +++ b/frontend/src/store/application.js @@ -31,6 +31,7 @@ export const useApplicationStore = defineStore('application', { applicationMap: new Map(), applicationUploadedDocuments: [], + isApplicationDocumentsLoading: false, }), actions: { setApplicationId(value) { @@ -148,10 +149,13 @@ export const useApplicationStore = defineStore('application', { }, async getApplicationUploadedDocuments() { try { + this.isApplicationDocumentsLoading = true; this.applicationUploadedDocuments = await DocumentService.getApplicationUploadedDocuments(this.applicationId); } catch (error) { console.log(error); throw error; + } finally { + this.isApplicationDocumentsLoading = false; } }, }, diff --git a/frontend/src/store/summaryDeclaration.js b/frontend/src/store/summaryDeclaration.js index 19035b07..629c0759 100644 --- a/frontend/src/store/summaryDeclaration.js +++ b/frontend/src/store/summaryDeclaration.js @@ -219,20 +219,6 @@ export const useSummaryDeclarationStore = defineStore('summaryDeclaration', { this.setSummaryModel(summaryModel); } - //new app only (i think this if block could be part of the one above?) - if (payload.application?.organizationId) { - const config = { - params: { - allFiles: true, - }, - }; - summaryModel['allDocuments'] = ( - await ApiService.apiAxios.get( - ApiRoutes.SUPPORTING_DOCUMENT_UPLOAD + '/' + payload.application.applicationId, - config, - ) - ).data; - } for (const facility of summaryModel.facilities) { const index = summaryModel.facilities.indexOf(facility); @@ -288,19 +274,10 @@ export const useSummaryDeclarationStore = defineStore('summaryDeclaration', { ).data; this.setSummaryModel(summaryModel); - if (summaryModel.allDocuments && summaryModel.allDocuments.length > 0) { - const allDocuments = summaryModel.allDocuments; - summaryModel.facilities[index].documents = allDocuments.filter( - (document) => document.ccof_facility === facility.facilityId, - ); - this.setSummaryModel(summaryModel); - } - isSummaryLoading.splice(index, 1, false); this.setIsSummaryLoading(isSummaryLoading); } // end FOR loop. FIXME: make loop brief enough to read in one view - summaryModel.allDocuments = null; if (!changeRecGuid) this.setIsLoadingComplete(true); } catch (error) { console.log(`Failed to load Summary - ${error}`); diff --git a/frontend/src/store/supportingDocumentUpload.js b/frontend/src/store/supportingDocumentUpload.js index 96f5dc18..5e9a0fe9 100644 --- a/frontend/src/store/supportingDocumentUpload.js +++ b/frontend/src/store/supportingDocumentUpload.js @@ -30,13 +30,5 @@ export const useSupportingDocumentUploadStore = defineStore('supportingDocumentU throw error; } }, - async deleteDocuments(deletedFiles) { - try { - await ApiService.apiAxios.delete(ApiRoutes.SUPPORTING_DOCUMENT_UPLOAD, { data: deletedFiles }); - } catch (error) { - console.error(error); - throw error; - } - }, }, }); diff --git a/frontend/src/utils/common.js b/frontend/src/utils/common.js index 57b155e3..45cbe55d 100644 --- a/frontend/src/utils/common.js +++ b/frontend/src/utils/common.js @@ -32,10 +32,6 @@ export function deepCloneObject(objectToBeCloned) { return clone(objectToBeCloned); } -export function getFileExtension(fileName) { - if (fileName) return fileName.slice(fileName.lastIndexOf('.') + 1); - return ''; -} export function isNullOrBlank(value) { return value === null || value === undefined || value === ''; } diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 7c96f722..a0ee77bb 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -331,6 +331,42 @@ export const PARENT_FEE_FREQUENCIES = Object.freeze({ export const DOCUMENT_TYPES = Object.freeze({ APPLICATION_AFS: 'AFS - Supporting Documents', APPLICATION_AFS_SUBMITTED: 'AFS - Supporting Documents - Submitted', + APPLICATION_LICENCE: 'Facility License', + APPLICATION_SUPPORTING: 'SUPPORTING', CR_NOTIFICATION_FORM: 'NOTIFICATION_FORM', CR_NOTIFICATION_FORM_SUPPORTING: 'SUPPORTING_DOC', }); + +export const MAX_FILE_SIZE = 2100000; // 2.18 MB is max size since after base64 encoding it might grow upto 3 MB. + +export const FILE_REQUIREMENTS_TEXT = + 'The maximum file size is 2MB for each document. Accepted file types are jpg, jpeg, heic, png, pdf, docx, doc, xls, and xlsx.'; + +export const FILE_EXTENSIONS_ACCEPT_TEXT = 'PDF, JPEG, JPG, PNG, HEIC, DOC, DOCX, XLS and XLSX'; + +export const FILE_EXTENSIONS_ACCEPT = Object.freeze([ + 'pdf', + 'png', + 'jpg', + 'jpeg', + 'heic', + 'doc', + 'docx', + 'xls', + 'xlsx', +]); + +export const FILE_TYPES_ACCEPT = Object.freeze([ + 'image/png', + 'image/jpeg', + 'image/jpg', + '.pdf', + '.png', + '.jpg', + '.jpeg', + '.heic', + '.doc', + '.docx', + '.xls', + '.xlsx', +]); diff --git a/frontend/src/utils/file.js b/frontend/src/utils/file.js index a8f8ffb1..192237c0 100644 --- a/frontend/src/utils/file.js +++ b/frontend/src/utils/file.js @@ -1,3 +1,5 @@ +import { FILE_EXTENSIONS_ACCEPT, MAX_FILE_SIZE } from '@/utils/constants.js'; + /** * Converting bytes to human readable values (KB, MB, GB, TB, PB, EB, ZB, YB) * @param {*} bytes @@ -13,18 +15,9 @@ export function humanFileSize(bytes, decimals = 2) { return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } -/** - * Get extension from file name - * https://stackoverflow.com/a/12900504 - * "" --> "" - * "name" --> "" - * "name.txt" --> "txt" - * ".htpasswd" --> "" - * "name.with.many.dots.myext" --> "myext" - * @param {*} fileName - */ export function getFileExtension(fileName) { - return fileName.slice(((fileName.lastIndexOf('.') - 1) >>> 0) + 2); + if (fileName) return fileName.slice(fileName.lastIndexOf('.') + 1); + return ''; } export function getFileExtensionWithDot(fileName) { @@ -39,3 +32,33 @@ export function getFileNameWithMaxNameLength(fileName, nameLength = 30, extensio return name + extension; } + +export function readFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsArrayBuffer(file); + reader.onload = () => { + const arrayBuffer = reader.result; + const binaryString = new Uint8Array(arrayBuffer).reduce((acc, byte) => acc + String.fromCharCode(byte), ''); + const base64String = window.btoa(binaryString); // Convert to Base64 + const doc = { + filename: getFileNameWithMaxNameLength(file.name), + filesize: file.size, + documentbody: base64String, + }; + resolve(doc); + }; + reader.onabort = () => { + reject(new Error(`Error reading file: ${reader.error?.message || 'Unknown error'}`)); + }; + reader.onerror = () => { + reject(new Error(`Error reading file: ${reader.error?.message || 'Unknown error'}`)); + }; + }); +} + +export function isValidFile(file) { + const isLessThanMaxSize = file?.size < MAX_FILE_SIZE; + const isFileExtensionAccepted = FILE_EXTENSIONS_ACCEPT.includes(getFileExtension(file?.name)?.toLowerCase()); + return isLessThanMaxSize && isFileExtensionAccepted; +} diff --git a/frontend/src/utils/rules.js b/frontend/src/utils/rules.js index 04bc3e31..1c6d4c6b 100644 --- a/frontend/src/utils/rules.js +++ b/frontend/src/utils/rules.js @@ -1,3 +1,6 @@ +import { FILE_EXTENSIONS_ACCEPT, FILE_EXTENSIONS_ACCEPT_TEXT, MAX_FILE_SIZE } from '@/utils/constants'; +import { getFileExtension, humanFileSize } from '@/utils/file'; + const rules = { email: [(v) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(v) || 'A valid email is required'], required: [ @@ -34,6 +37,28 @@ const rules = { }, wholeNumber: (v) => !v || /^\d+$/.test(v) || 'A valid whole number is required', phone: (v) => /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/.test(v) || 'A valid phone number is required', // https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch04s02.html + fileRules: [ + (v) => !!v || 'This is required', + (value) => { + return !value || !value.length || value[0]?.name?.length < 255 || 'File name can be max 255 characters.'; + }, + (value) => { + return ( + !value || + !value.length || + value[0].size < MAX_FILE_SIZE || + `The maximum file size is ${humanFileSize(MAX_FILE_SIZE)} for each document.` + ); + }, + (value) => { + return ( + !value || + !value.length || + FILE_EXTENSIONS_ACCEPT.includes(getFileExtension(value[0].name)?.toLowerCase()) || + `Accepted file types are ${FILE_EXTENSIONS_ACCEPT_TEXT}.` + ); + }, + ], }; export default rules;