Strategic Software Consultant

I'm the partner of choice for many of the world's leading enterprises. I help businesses elevate their value through solution discovery, software development, design, QA, and consultancy services. As your Strategic Software Consultant, I work across many technology products and delivery teams. The scope of technology is broad, and it is my job to understand how your technology applications holistically support and connect to create a sustainable technology ecosystem. The ecosystem of technologies may span to 3rd party applications, in-house developed solutions, business intelligence, data integrations, and of course, SaaS applications.

Oracle Certified Specialist
B2C Service REST API Browser UI Extension Interview Extension Oracle B2C Service Oracle Intelligent Advisor

Circumvent Intelligent Advisor 50 MB Max File Attachment Limit

This is the B2B Service FAQ describing the 50 MB file attachment limit.

https://cx.rightnow.com/app/answers/detail/a_id/11619

Set Upload Control Custom Properties

AttributeValuesRequiredNotes
namexUploadyesthis creates the custom upload
max filesany numbernoonly required if you want to set a max
extensions allowed.jpg,.jpegnorestrict to file type.  Note the period before the extension is required.  Comma separated values
requiredtrue or falseno
categoryany stringyesthis creates the unique upload group.

Save modal.css file to resources folder.

The modal.css is used to create a nice preview when clicking on the thumbnail image.

/* Style the Image Used to Trigger the Modal */
#myImg {
  border-radius: 5px;
  cursor: pointer;
  transition: 0.3s;
}

#myImg:hover {opacity: 0.7;}

/* The Modal (background) */
.modal {
  display: none; /* Hidden by default */
  position: fixed; /* Stay in place */
  z-index: 1; /* Sit on top */
  padding-top: 100px; /* Location of the box */
  left: 0;
  top: 0;
  width: 100%; /* Full width */
  height: 100%; /* Full height */
  overflow: auto; /* Enable scroll if needed */
  background-color: rgb(0,0,0); /* Fallback color */
  background-color: rgba(0,0,0,0.9); /* Black w/ opacity */
}

/* Modal Content (Image) */
.modal-content {
  margin: auto;
  display: block;
  width: 80%;
  max-width: 700px;
}

/* Caption of Modal Image (Image Text) - Same Width as the Image */
#caption {
  margin: auto;
  display: block;
  width: 80%;
  max-width: 700px;
  text-align: center;
  color: #ccc;
  padding: 10px 0;
  height: 150px;
}

/* Add Animation - Zoom in the Modal */
.modal-content, #caption {
  animation-name: zoom;
  animation-duration: 0.6s;
}

@keyframes zoom {
  from {transform:scale(0)}
  to {transform:scale(1)}
}

/* The Close Button */
.close {
  position: absolute;
  top: 15px;
  right: 35px;
  color: #f1f1f1;
  font-size: 40px;
  font-weight: bold;
  transition: 0.3s;
}

.close:hover,
.close:focus {
  color: #bbb;
  text-decoration: none;
  cursor: pointer;
}

/* 100% Image Width on Smaller Screens */
@media only screen and (max-width: 700px){
  .modal-content {
    width: 100%;
  }
}

Save fileUpload.css file to resources folder.

This styles your custom upload group

.uploadButton{
    border: 2px dashed gray;
    background: #F5F5F5;
    width: 75px;
    height: 75px;
    vertical-align: top;
    border-radius: 8px;
	 display: inline-block;
	 text-align: center;
	 line-height: 75px;
	 font-size:12px;
	 margin-right:20px;
    cursor: pointer;
}

.preview {
    vertical-align: top;
    margin-right: 20px;
    display: inline-block;
    width:75px;
    height:75px;
    margin-right:20px;
}

.uploadNote{
	 height:70px;
    display: inline-block;
    width: calc(100% - 120px);
}

.uploadGroup{
   padding: 10px;
   border: solid 1px #808080;
   margin-bottom: 20px;
   border-radius: 5px;
   min-width: 200px;
}

.deleteWrapper{
   margin-top:5px;
}

.deleteWrapper svg{
   margin-right:10px;
}

Add fileUpload.js Interview Extension to your resources folder.

This does all the magic to display and upload your files.

var appName = "OIA_File_Attach_Upload";
var appVersion = "1.0";
var isDevMode = false;
var isLocal = true; // don't use BUI extension code when debugging locally.
var currentWindow = window.location.href;
var baseURI = 'https://example.custhelp.com/';
var incidentId = null;
var uploadControl = {};

if(currentWindow.indexOf("localhost") == -1){
	isLocal = false;
}
if(currentWindow.indexOf("--tst") == -1){
	isDevMode = true;
}
if(isDevMode){
	baseURI = 'https://example--tst1.custhelp.com/';
}

OraclePolicyAutomation.AddExtension({
	customUpload: function (control) {
		if (control.getProperty("name") == "xUpload") {
			return {
				mount: function (el) {
					if(checkRequiredAttributes(control)){
						addModalHTML(el);
						if(isLocal){
							makeUploadGroup(el, control);
						} else{
							init_bui(el, control);
						}
					}
				},
				unmount: function () {
					// remove files from Interview so they are not uploaded when the interview is submitted;
					var uploads = control.getUploads();
					if(uploads.length){
						for(var i in uploads){
							if (uploads.hasOwnProperty(i)) {
								control.removeUpload(i);
							}
						}
					}
				},
			};
		}
	}
});

function init_bui(el, control){
	var script = document.createElement('script');
	script.type = 'text/javascript';
	script.async = true;
	script.src = baseURI + '/AgentWeb/module/extensibility/js/client/core/extension_loader.js';
	script.onload = function() {
		getGlobalContext().then(function () {
			getExtensionProvider().then(function (extensionProvider) {
				extensionProvider.registerWorkspaceExtension(function(workspaceRecord){
					incidentId = workspaceRecord.getWorkspaceRecordId();
				    loadCategoryFiles(el, control);
				});
			});
		});
	};
	document.head.appendChild(script);
}

function checkRequiredAttributes(control){
	if(control.getProperty("category") == null){
		alert('Attachment missing attribute:  Category');
		return false;
	}
	fileRequired = control.getProperty("required");
	maxFiles = parseInt(control.getProperty("max files"));
	// set attributes for the controller upload group
	uploadControl[control.id] = {};
	uploadControl[control.id].maxFiles = (maxFiles) ? maxFiles : 1000;
	uploadControl[control.id].fileCount = 0;
	uploadControl[control.id].fileRequired = (fileRequired == 'true') ? true : false;
	uploadControl[control.id].category = control.getProperty("category");
	uploadControl[control.id].filesUploaded = {};
	return true;
}

// modal used to display preview of image
function addModalHTML(el){
	var modal = document.createElement('div');
	var span = document.createElement('span');
	var img = document.createElement("img");
	var caption = document.createElement('div');
	modal.id = "modal";
	modal.setAttribute("class", "modal");
	modal.onclick = function () {
		modal.style.display = "none";
	};
	span.setAttribute("class", "close");
	span.innerHTML = '×';
	img.id = 'modalImage';
	img.setAttribute("class", "modal-content");
	caption.id = 'caption';
    modal.appendChild(span);
	modal.appendChild(img);
	modal.appendChild(caption);
	el.appendChild(modal);
}

function loadCategoryFiles(el, control){
	var getCurrentAttachments = new ORACLE_SERVICE_CLOUD.ExtensionPromise();
	var category = uploadControl[control.id].category;
    var query = "/connect/v1.4/queryResults?query=SELECT id, AttachmentNotes, FileAttachments.ID as fileId, FileAttachments.FileName from CO.FileAttachments where Category = '"+category+"' and Incident = " + incidentId;
	var promiseArray = [];
	getQueryResult(query, "getFileAttachments").then(function (queryResponse) {
        getCurrentAttachments.resolve();
		rows = queryResponse.items[0].rows;
		for(var i in rows){
			if (rows.hasOwnProperty(i)) {
				let uploadGroupId = control.id + i;
				let fileAttachId = rows[i][0];
				uploadControl[control.id].fileCount++;
				uploadControl[control.id].filesUploaded[uploadGroupId] = fileAttachId;
				// div wrapper
				div = document.createElement("div");
				div.id = 'wrapper_' + uploadGroupId;
				div.setAttribute("class", "uploadGroup");
				// preview
				fileName = rows[i][3];
				fileId = rows[i][2];
				fileNameParts = fileName.split('.');
				fileExtension = fileNameParts[1];
				if ( /\.(jpe?g|png|gif)$/i.test(fileName) ) {
					// show image
					preview = document.createElement("img");
					preview.id = fileId;
					preview.setAttribute("class", "preview");
					preview.style.cursor = "pointer";
					div.appendChild(preview);
					let imageQuery = '/connect/v1.4/CO.FileAttachments/'+fileAttachId+'/FileAttachments/'+fileId+'/data';
					let returnData = {};
					returnData.fileId = fileId;
					returnData.fileExtension = fileExtension;
					promiseArray.push(getQueryResult(imageQuery, "getFileAttachments", returnData));
				} else{
					// show non-image box
					preview = document.createElement("div");
					preview.innerHTML = '<div class="opa-file-item"><div class="opa-file-preview"><div class="opa-file-item-file"><label>'+fileExtension+'</label></div></div></div>';
					div.appendChild(preview);
				}

				// create textarea for the note field
				attachmentNote = document.createElement("textarea");
				note = rows[i][1];
				attachmentNote.name = "uploadNote";
				attachmentNote.id = "uploadNote";
				attachmentNote.maxLength = "5000";
				attachmentNote.setAttribute("class", "uploadNote");
				attachmentNote.setAttribute("placeholder", "Add attachment note");
				attachmentNote.value = note;
				attachmentNote.onchange = function () {
					saveNote(control, uploadGroupId, this.value);
				};
				div.appendChild(attachmentNote);
				// delete button
				deleteButton = makeDeleteButton(div, uploadGroupId, fileName, el, control);
				// append
				div.appendChild(deleteButton);
				el.appendChild(div);
			}
		}
		if(uploadControl[control.id].fileCount < uploadControl[control.id].maxFiles){
			makeUploadGroup(el, control);
		}
		if(promiseArray.length){
			Promise.all(promiseArray).then(function(results) {
				for(var x in results){
					if (results.hasOwnProperty(x)) {
						let queryResults = results[x];
						let thumbnail = document.getElementById(queryResults.returnData.fileId);
						thumbnail.src = 'data:image/'+queryResults.returnData.fileExtension+';base64, ' + queryResults.data;
						thumbnail.onclick = function () {
							var modal = document.getElementById("modal");
							var modalImg = document.getElementById("modalImage");
							var captionText = document.getElementById("caption");
							modal.style.display = "block";
							modalImg.src = thumbnail.src;
							captionText.innerHTML = fileName;
						};
					}
				}
			}).catch(function (errorMSG) {
				console.log(errorMSG);
			});
		}
	});
}

function makeUploadGroup(el, control){
	var uploadButton, fileInput;
	var uploadGroupId = control.id + Date.now(); // used to create a unique key value
    // create DOM wrapper
	div = document.createElement("div");
	div.id = 'wrapper_' + uploadGroupId;
	div.setAttribute("class", "uploadGroup");
    // create "Add File" upload button
	uploadButton = document.createElement("label");
	uploadButton.innerHTML = 'Add File';
	uploadButton.setAttribute("class", "uploadButton");
	uploadButton.id = uploadGroupId;
    // create input upload
	fileInput = document.createElement("input");
	fileInput.setAttribute("type", "file");
	fileInput.setAttribute("id", "file-input");
	fileInput.setAttribute("style", "display:none");
	if(control.getProperty("extensions allowed") != ''){
		fileInput.setAttribute("accept", control.getProperty("extensions allowed"));
	}

	fileInput.onchange = function (evt) {
		let fileList = evt.target.files; // capture uploaded file
        // create attachment note field and append to upload group
		let attachmentNote = document.createElement("textarea");
		attachmentNote.name = "uploadNote";
		attachmentNote.id = "uploadNote";
		attachmentNote.maxLength = "5000";
		attachmentNote.setAttribute("class", "uploadNote");
		attachmentNote.setAttribute("placeholder", "Add attachment note");
		attachmentNote.onchange = function () {
			saveNote(control, uploadGroupId, this.value);
		};
		div = document.getElementById('wrapper_' + uploadGroupId);
		div.appendChild(attachmentNote);
		// read the uploaded file
		file = fileList[0]; // get the first and only element
		if(uploadControl[control.id].fileRequired){
			control.addFiles(evt.target.files); // add uploaded file to interview to
		}
		readImage(file, uploadGroupId, el, control);
		// increment the total file count
		uploadControl[control.id].fileCount++;
		// add another upload group if the total uploads is less than the the defined max files
		if(uploadControl[control.id].fileCount < uploadControl[control.id].maxFiles){
			makeUploadGroup(el, control);
		}
	};
	// append file upload button to file Input
	uploadButton.appendChild(fileInput);
	// append upload button to wrapper
	div.appendChild(uploadButton);
	// append wrapper to control element
	el.appendChild(div);
}

function readImage(file,  uploadGroupId, el, control) {
	const reader = new FileReader();
	reader.onload = function (event) {
	  file.dataURI = event.target.result;
	  file.textNote = null;
	  uploadButton = document.getElementById(uploadGroupId);
	  uploadButton.setAttribute("style", "display:none");
	  if ( /\.(jpe?g|png|gif)$/i.test(file.name) ) { // create preview for image files only
		preview = document.createElement("img");
		preview.src = file.dataURI;
		preview.setAttribute("class", "preview");
		preview.style.cursor = "pointer";
		preview.onclick = function () {
			var modal = document.getElementById("modal");
			var modalImg = document.getElementById("modalImage");
			var captionText = document.getElementById("caption");
			modal.style.display = "block";
			modalImg.src = file.dataURI;
			captionText.innerHTML = file.name;
		};
	  } else{
		fileNameParts = file.name.split('.'); // create an array of the file name delimited by a period
		preview = document.createElement("div");
		preview.setAttribute("class", "preview");
		preview.innerHTML = '<div class="opa-file-item"><div class="opa-file-preview"><div class="opa-file-item-file"><label>'+fileNameParts[1]+'</label></div></div></div>';
	  }
	  div = document.getElementById('wrapper_' + uploadGroupId);
	  div.insertBefore(preview, uploadButton);
	  deleteButton = makeDeleteButton(div, uploadGroupId, file.name, el, control);
	  div.appendChild(deleteButton);
	  saveRecordExternal(file, control, uploadGroupId);
	};
	reader.readAsDataURL(file);
}

function saveNote(control, uploadGroupId, note){
	if(!isLocal){
		var saveFileAttachmentNote = new ORACLE_SERVICE_CLOUD.ExtensionPromise();
		var data = '{"AttachmentNotes": "'+note+'"}';
		var query = "/connect/v1.4/CO.FileAttachments/" + uploadControl[control.id].filesUploaded[uploadGroupId];
		var source = "save file note attachment";
		patchQueryData(query, data, source).then(function () {
			saveFileAttachmentNote.resolve();
		}).catch(function (errorMSG) {
			alert(errorMSG);
			saveFileAttachmentNote.resolve();
		});
	}
}

function saveRecordExternal(file, control, uploadGroupId){
	if(!isLocal){
		var dataURIParts = file.dataURI.split(',');
		var category = uploadControl[control.id].category;
		var createFileAttachmentRecord = new ORACLE_SERVICE_CLOUD.ExtensionPromise();
		var data = '{"Incident": {"id":'+incidentId+'}, "Category": "'+category+'","FileAttachments": {"fileName":"'+file.name+'", "data": "'+dataURIParts[1]+'"}}';
		var query = "/connect/v1.4/CO.FileAttachments/";
		var source = "add incident parts record";

		postQueryData(query, data, source).then(function (jsonResponse) {
			fileAttachId = jsonResponse.id;
			uploadControl[control.id].filesUploaded[uploadGroupId] = fileAttachId;
			createFileAttachmentRecord.resolve();
		}).catch(function (errorMSG) {
			alert(errorMSG);
			createFileAttachmentRecord.resolve();
		});
	} else{
		// add dummy file to for local debugging
		uploadControl[control.id].filesUploaded[uploadGroupId] = Date.now();
	}
}

function makeDeleteButton(uploadGroup, uploadGroupId, fileName, el, control){
	var divWrapper = document.createElement("div");
	var deleteButton = document.createElement("span");
	var text = document.createTextNode(fileName);
	divWrapper.setAttribute("class", "deleteWrapper");
	deleteButton.setAttribute("role", "button");
	deleteButton.setAttribute("title", "Delete");
	deleteButton.style.cursor = "pointer";
	deleteButton.innerHTML = '<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512" style="width: 16px; height: 16px;"><path fill="currentColor" d="M432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16zM53.2 467a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128H32z"></path></svg>';
	deleteButton.onclick = function () {
		// delete from B2B Service Object
		if(!isLocal){
			var query = "/connect/v1.4/CO.FileAttachments/" + uploadControl[control.id].filesUploaded[uploadGroupId];
			deleteRecord(query, 'Delete File Attachment'); // remove from database
		}
		// remove from DOM
		uploadGroup.parentNode.removeChild(uploadGroup);
		// create another upload group if max count is not met
		if(uploadControl[control.id].fileCount == uploadControl[control.id].maxFiles){
			makeUploadGroup(el, control);
		}
		// decrement total file count
		uploadControl[control.id].fileCount--;
		delete uploadControl[control.id].filesUploaded[uploadGroupId]; // remove from JS local variable
	};
	divWrapper.appendChild(deleteButton);
	divWrapper.appendChild(text);
	return divWrapper;
}

			alert(errorMSG);
			createFileAttachmentRecord.resolve();
		});
	}
}

function makeDeleteButton(uploadGroup, category, photoGroupId, fileName){
	var divWrapper = document.createElement("div");
	var deleteButton = document.createElement("span");
	var text = document.createTextNode(fileName);
	divWrapper.setAttribute("class", "deleteWrapper");
	deleteButton.setAttribute("role", "button");
	deleteButton.setAttribute("title", "Delete");
	deleteButton.style.cursor = "pointer";
	deleteButton.innerHTML = '<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512" style="width: 16px; height: 16px;"><path fill="currentColor" d="M432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16zM53.2 467a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128H32z"></path></svg>';
	deleteButton.onclick = function () {
		fileCount--;
		uploadGroup.parentNode.removeChild(uploadGroup); // remove from DOM
		if(!isLocal){
			var query = "/connect/v1.4/CO.FileAttachments/" + filesUploaded[photoGroupId];
			deleteRecord(query, 'Delete File Attachment'); // remove from database
		}
		delete filesUploaded[photoGroupId]; // remove from JS local variable
	};
	divWrapper.appendChild(deleteButton);
	divWrapper.appendChild(text);
	return divWrapper;
}

Add aUtils.js file to your resources folder.

This allows the interview extension to interact with the B2B Service BUI Incident workspace and interact with the B2B service REST API

function getExtensionProvider() {
    extensionProviderPromise = ORACLE_SERVICE_CLOUD.extension_loader.load(appName, appVersion);
    return extensionProviderPromise;
}


function getGlobalContext() {
	var globalContextPromise_1 = new ORACLE_SERVICE_CLOUD.ExtensionPromise();
	globalContextPromise = globalContextPromise_1;
	getExtensionProvider().then(function (extensionProvider) {
		extensionProvider.getGlobalContext().then(function (globalContext) {
			globalContextPromise_1.resolve(globalContext);
		});
	});
	return globalContextPromise_1;
}


function getSessionToken() {
    return new Promise(function (resolve, reject){
        getGlobalContext().then(function (globalCtx) {

            globalCtx.getSessionToken().then(function (sessionToken) {

                tokenObj = {
                    sessionToken: sessionToken,
                    url: globalCtx.getInterfaceServiceUrl('REST'),
                    interfaceUrl: globalCtx.getInterfaceUrl(),
                    acctID: globalCtx.getAccountId().toString(),
                    login: globalCtx.login,
                    profile: globalCtx.profileName
                };
                resolve(tokenObj);
            });
        });
    });
}

function getWorkspaceExtension() {
	var workspaceExtensionPromise_1 = new ORACLE_SERVICE_CLOUD.ExtensionPromise();
	workspaceExtensionPromise = workspaceExtensionPromise_1;
	getExtensionProvider().then(function (extensionProvider) {
		extensionProvider.registerWorkspaceExtension(function (WRecord) {
			workspaceExtensionPromise_1.resolve(WRecord);
		});
	});
	return workspaceExtensionPromise_1;
}

function getQueryResult(query, source, returnData)
{
   return new Promise(function (resolve, reject) {
      getSessionToken().then(function(sessionObj){
         var xhr = new XMLHttpRequest();
         xhr.open('GET', sessionObj.url + query);
         xhr.setRequestHeader('Content-Type', 'application/json');
         xhr.setRequestHeader('OSvC-CREST-Application-Context', 'BUI Add-In Utils');
         xhr.setRequestHeader('Authorization', 'Session ' + sessionObj.sessionToken);
         xhr.onload = function() {
            if (xhr.status === 200) {
               var jsonResponse = JSON.parse(xhr.responseText);
               if(returnData){
                   jsonResponse.returnData = returnData;
               }
               resolve(jsonResponse);
            }
            else if (xhr.status !== 200) {
               reject(xhr.status);
            }
         };
         xhr.send();
      });
   });
}

function deleteRecord(query, source)
{
	getSessionToken().then(function(sessionObj){
		var xhr = new XMLHttpRequest();
		xhr.open('DELETE', sessionObj.url + query);
		xhr.setRequestHeader('Content-Type', 'application/json');
		xhr.setRequestHeader('OSvC-CREST-Application-Context', 'BUI Add-In Utils');
		xhr.setRequestHeader('Authorization', 'Session ' + sessionObj.sessionToken);
		xhr.send();
	});
	queryResponse = new ORACLE_SERVICE_CLOUD.ExtensionPromise();
	return queryResponse;
}


function postQueryData(query, data, source)
{
	getSessionToken().then(function(sessionObj){
		var xhr = new XMLHttpRequest();
		xhr.open('POST', sessionObj.url + query);
		xhr.withCredentials = true;
		xhr.setRequestHeader("Content-Type", "application/json");
		xhr.setRequestHeader("OSvC-CREST-Application-Context", "SR Automation");
		xhr.setRequestHeader('Authorization', 'Session ' + sessionObj.sessionToken);
		xhr.onload = function() {
			if (xhr.status === 200 || xhr.status === 201) {
				var jsonResponse = {};
				if(xhr.responseText != ""){
					jsonResponse = JSON.parse(xhr.responseText);
                }
				queryResponse.resolve(jsonResponse);
			}
			else {
            jsonResponseError = JSON.parse(xhr.responseText);
				queryResponse.reject(xhr.status + ': ' + jsonResponseError.detail);
			}
		};
		xhr.send(data);
	});
	queryResponse = new ORACLE_SERVICE_CLOUD.ExtensionPromise();
	return queryResponse;
}

function patchQueryData(query, data, source)
{
	getSessionToken().then(function(sessionObj){
		var xhr = new XMLHttpRequest();
		xhr.open('POST', sessionObj.url + query);
		xhr.withCredentials = true;
		xhr.setRequestHeader("Content-Type", "application/json");
		xhr.setRequestHeader("OSvC-CREST-Application-Context", "SR Automation");
		xhr.setRequestHeader("X-HTTP-Method-Override", "PATCH");
		xhr.setRequestHeader('Authorization', 'Session ' + sessionObj.sessionToken);
		xhr.onload = function() {
			if (xhr.status === 200 || xhr.status === 201) {
				var jsonResponse = {};
				if(xhr.responseText != ""){
					jsonResponse = JSON.parse(xhr.responseText);
                }
				queryResponse.resolve(jsonResponse);
			}
			else {
                jsonResponseError = JSON.parse(xhr.responseText);
				queryResponse.reject(xhr.status + ': ' + jsonResponseError.detail);
			}
		};
		xhr.send(data);
	});
	queryResponse = new ORACLE_SERVICE_CLOUD.ExtensionPromise();
	return queryResponse;
}

Create Custom Object CO.FileAttachments to store your files.

Leave a comment

Your email address will not be published.