Periodic Review, Part VIII

“Code is like humor. When you have to explain it, it’s bad.”
Cory House

Last time, we built out the processExecution function in our utility Script Include, and now we need to continue on by building out the sendNotices function. Before we build it out completely, though, let’s just create a quick stub so that we can test out the work so far. This ought to do the trick.

sendNotices: function(configurationGR, executionGR, noticeGR, noticeItemGR, tempNotice) {
	gs.info('PeriodicReviewUtils.sendNotices: ' + configurationGR.getUniqueValue() + '; ' + executionGR.getUniqueValue() + '; ' + noticeGR.getUniqueValue() + '; ' + noticeItemGR.getUniqueValue() + '; ' + tempNotice);
}

Now we just need to make sure that we have the next run date set correctly on our lone example configuration and then we can navigate over to Scripts – Background and type in our earlier testing code.

var pru = new PeriodicReviewUtils();
pru.dailyProcess();
gs.info('Done!');

And once again we can hit that Run script button and see what happens.

Initial test results

Well, that’s not good! It seems that there is some kind of problem with our calculateNextRunDate function. It’s always something! But then, that’s why I like to test things out as I go rather than waiting for the entire thing to be built to try things out. The good news is that it seems to have run all of the way through and made it all the way to the end, where it updates the run date, before it crashed and burned. Let me dig into that function and see what might be wrong.

(insert brief debugging pause)

OK, I found the issue. Apparently, it doesn’t like this line:

var runDate = new Date(configurationGR.getDisplayValue('next_scheduled_date'));

The next scheduled date on the configuration record is a GlideDate, and the display value is year, month, and then day. The Javascript Date constructor doesn’t like that, although I know that I have done that in the past without issue. Still, it doesn’t really matter because the value should be today’s date; otherwise, we wouldn’t be running right now. So we can simple change that to this:

var runDate = new Date();

That should still get us the date that we are looking for, so let’s hop back over to Scripts – Background and try this again.

Successful test results

That’s better. So far, so good. Now we need to actually build out that sendNotices function to group all of the notice item records by recipient, create a notice record for each recipient, and then send out the notices. We can develop an unduplicated list of recipients by using a GlideAggregate.

var noticeItemGA = new GlideAggregate('x_11556_periodic_r_review_notice_item');
noticeItemGA.addQuery('review_notice', tempNotice);
noticeItemGA.addAggregate('COUNT');
noticeItemGA.groupBy('recipient');
noticeItemGA.orderBy('recipient');
noticeItemGA.query();
while (noticeItemGA.next()) {
	var recipient = noticeItemGA.getValue('recipient');
	...
}

Once we have the recipient, we can create the notice record for that recipient.

noticeGR.initialize();
noticeGR.review_execution = executionGR.getUniqueValue();
noticeGR.recipient = recipient;
noticeGR.short_description = executionGR.getDisplayValue('run_date') + ' review notice for ' + configurationGR.getDisplayValue('short_description');
noticeGR.insert();

Now that a notice record for this recipient has been created, we need to go back and find all of the notice item records that are associated with this recipient and update the reference to the notice record from the temporary notice to the newly created notice for this recipient.

noticeItemGR.initialize();
noticeItemGR.addQuery('recipient', recipient);
noticeItemGR.addQuery('review_notice', tempNotice);
noticeItemGR.query();
while (noticeItemGR.next()) {
	noticeItemGR.review_notice = noticeGR.getUniqueValue();
	noticeItemGR.update();
}

At this point, we should be able to send out the notice, but as that is a rather complex process in and of itself, let’s just stub things out for now so that we can test out the process so far.

gs.info('This is where we would send a notice to ' + noticeGR.getDisplayValue('recipient'));

All together, our new sendNotices function now looks like this.

sendNotices: function(configurationGR, executionGR, noticeGR, noticeItemGR, tempNotice) {
	gs.info('PeriodicReviewUtils.sendNotices: ' + configurationGR.getUniqueValue() + '; ' + executionGR.getUniqueValue() + '; ' + noticeGR.getUniqueValue() + '; ' + noticeItemGR.getUniqueValue() + '; ' + tempNotice);
var noticeCt = 0;

	var noticeItemGA = new GlideAggregate('x_11556_periodic_r_review_notice_item');
	noticeItemGA.addQuery('review_notice', tempNotice);
	noticeItemGA.addAggregate('COUNT');
	noticeItemGA.groupBy('recipient');
	noticeItemGA.orderBy('recipient');
	noticeItemGA.query();
	while (noticeItemGA.next()) {
		var recipient = noticeItemGA.getValue('recipient');
		noticeGR.initialize();
		noticeGR.review_execution = executionGR.getUniqueValue();
		noticeGR.recipient = recipient;
		noticeGR.short_description = executionGR.getDisplayValue('run_date') + ' review notice for ' + configurationGR.getDisplayValue('short_description');
		noticeGR.insert();
		noticeItemGR.initialize();
		noticeItemGR.addQuery('recipient', recipient);
		noticeItemGR.addQuery('review_notice', tempNotice);
		noticeItemGR.query();
		while (noticeItemGR.next()) {
			noticeItemGR.review_notice = noticeGR.getUniqueValue();
			noticeItemGR.update();
		}
		gs.info('This is where we would send a notice to ' + noticeGR.getDisplayValue('recipient'));
	}
}

That should be enough to give things the old college try. Let’s pop back over to Scripts – Background and paste in our testing code and see what we get.

Testing results

Nice! Obviously, a lot more testing is in order before we can say for sure that everything works for every scenario, but things are looking pretty good. Next time, let’s see if we can actually send out a notice.

Periodic Review, Part VII

“Whether you want to uncover the secrets of the universe, or you just want to pursue a career in the 21st century, basic computer programming is an essential skill to learn.”
Stephen Hawking

Last time, we got started on our script that will run the review process by building and testing the primary function that finds all of the configurations that will need to run. Today we need to continue on with the script, beginning with the process for a single configuration’s execution. Before we get started, though, we need to talk a little bit about making sure that the notice will go to somebody.

Both the configuration record and the system as a whole have a default recipient/email address. The system default email address is contained in a System Property and the configuration default recipient is on the configuration record. There is a field on the source record for the recipient, and if that recipient record is valid and active and contains an email address, then that will be the address that we will want to use. If not, then we will want to use the configuration fallback recipient, and if there is some issue in the notice process with that recipient’s email address, then we will end up using the system fallback address. This is to ensure that the notice will actually get to a real person who can take action on the review request.

There is also another optional System Property that can be used to limit notifications to a specific email domain. If that value is present, and the recipient email is not an email address of that domain, then the fallback recipient will be used as well. All of this should be documented in the notice item record. So the first thing that we will want to do will be to pull those System Properties.

// fetch system properties
var systemFallbackAddress = gs.getProperty('x_11556_periodic_r.fallback_email_address');
var systemEmailDomain = gs.getProperty('x_11556_periodic_r.email_domain');

The next thing that we will want to do will be to create a record in the Review Execution table to memorialize this execution.

// create execution record
var executionGR = new GlideRecord('x_11556_periodic_r_review_execution');
executionGR.configuration = configuration;
executionGR.run_date = new GlideDate();
executionGR.run_start = new GlideDateTime();
executionGR.state = 'Running';
executionGR.short_description = executionGR.getDisplayValue('run_date') + ' review notices';
executionGR.total_items = 0;
executionGR.total_notices = 0;
executionGR.completion_code = 0;
executionGR.insert();

This is standard GlideRecord stuff, so we don’t really need to go into a lot of detail here other than to say that we link it back to the configuration that is being executed and initialize all of the other values. Once we have our execution record created, we can pull the configuration record from the reference, and once we have it, we can set up the fallback recipient for this configuration.

var configurationGR = executionGR.configuration.getRefRecord();
var fallbackRecipient = configurationGR.getDisplayValue('fallback_recipient');

Now we need to go fetch all of the items that need to be reviewed this cycle, but because we want to consolidate all of the items for any given recipient into a single notice, we will first need to fetch them all, and then once we have the full list, group them by recipient. To facilitate that, we can create a temporary notice record and assign all of the items to that notice, and once we run through all of the query results, we can then fetch them back by recipient. So let’s create that temporary notice record now.

// create temporary notice record
var noticeGR = new GlideRecord('x_11556_periodic_r_review_notice');
noticeGR.review_execution = executionGR.getUniqueValue();
noticeGR.recipient = configurationGR.fallback_recipient;
noticeGR.short_description = 'Temporary notice record';
noticeGR.insert();
var tempNotice = noticeGR.getUniqueValue();

Once we have our notice record, we can use the table and filter from the configuration record to find all of the items to be reviewed.

// create notice item records from source table data
var noticeItemGR = new GlideRecord('x_11556_periodic_r_review_notice_item');
var itemCt = 0;
var sourceGR = new GlideRecord(table);
if (sourceGR.isValid()) {
	if (filter) {
		sourceGR.addEncodedQuery(filter);
	}
	sourceGR.orderBy(configurationGR.short_description_column);
	sourceGR.query();
	while (sourceGR.next()) {
		...
	}
	...
} else {
	...
}

Then, inside the query loop, we can build a record in the Review Notice Item table for each item.

noticeItemGR.initialize();
noticeItemGR.review_notice = tempNotice;
noticeItemGR.id = sourceGR.getUniqueValue();
noticeItemGR.short_description =  sourceGR.getDisplayValue(configurationGR.short_description_column);
noticeItemGR.description = sourceGR.getDisplayValue(configurationGR.description_column);
noticeItemGR.recipient = sourceGR.getValue(configurationGR.recipient_column);
noticeItemGR.insert();
itemCt++;

Once the notice item record has been created, we can inspect the recipient and come up with a valid recipient to which we will send the notice.

var recipientGR = new GlideRecord('sys_user');
recipientGR.get(noticeItemGR.getValue('recipient'));
var notes = '';
if (recipientGR.isValid()) {
	if (recipientGR.getValue('active')) {
		var email = recipientGR.getDisplayValue('email');
		if (email) {
			if (systemEmailDomain && !email.endsWith(systemEmailDomain)) {
				notes = 'Recipient email address is not an authoized email address; reverting to fallback recipient';
			}
		} else {
			notes = 'Specified recipient has no email address; reverting to fallback recipient';
		}
	} else {
		notes = 'Specified recipient is not active; reverting to fallback recipient';
	}
} else {
	notes = 'Recipient column empty on source record; reverting to fallback recipient';
}
if (notes) {
	noticeItemGR.notes = notes;
	noticeItemGR.recipient = fallbackRecipient;
	noticeItemGR.update();
}

Once we complete the loop, we will want to see if the query returned any items, and either process the items or close out this execution.

if (itemCt > 0) {
	executionGR.total_items = itemCt;
	executionGR.update();
	this.sendNotices(configurationGR, executionGR, noticeGR, noticeItemGR, tempNotice);
} else {

	// finalize the execution
	executionGR.total_items = 0;
	executionGR.total_notices = 0;
	executionGR.run_end = new GlideDateTime();
	executionGR.state = 'Complete';
	executionGR.completion_code = 0;
	executionGR.description = 'No items matched the filter criteria during this run, so no notices were sent out.';
	executionGR.update();
}

And once that is done, whether we had any items returned or not, we will want to delete that temporary notice record.

// delete the temporary notice
noticeGR.get(tempNotice);
noticeGR.deleteRecord();

One last thing: we need to do something in the event that we find ourselves in the else branch of the if (sourceGR.isValid()) { conditional. This basically means that the source table specified on the configuration record is not valid and so we cannot look for any items there. This is basically a failure of the execution, so we need to update the execution record to reflect that.

// finalize the execution
executionGR.total_items = 0;
executionGR.total_notices = 0;
executionGR.run_end = new GlideDateTime();
executionGR.state = 'Failed';
executionGR.completion_code = 1;
executionGR.description = 'The specified source table in the configuration record is not valid; execution cannot proceed; aborting execution.';
executionGR.update();

Failure is not an option that we originally set up for that field, so we will have to go back into the dictionary at some point and fix that to make that code work. Finally, regardless of how the execution turned out, we need to update the configuration record with the next run date, which is calculated in the function that we added for that purpose earlier.

// set the next run date
configurationGR.setValue('next_scheduled_date', this.calculateNextRunDate(configurationGR));
configurationGR.update();

With the addition of this completed function, our utility Script Include now looks like this.

var PeriodicReviewUtils = Class.create();
PeriodicReviewUtils.prototype = {
    initialize: function() {
    },

	dailyProcess: function() {
		var toRun = [];
		var configurationGR = new GlideRecord('x_11556_periodic_r_review_configuration');
		var today = new GlideDate();
		configurationGR.addQuery('next_scheduled_date', today);
		configurationGR.orderBy('number');
		configurationGR.query();
		while (configurationGR.next()) {
			var execution = {};
			execution.configuration = configurationGR.getUniqueValue();
			execution.table = configurationGR.getDisplayValue('table');
			execution.filter = configurationGR.getDisplayValue('filter');
			toRun.push(execution);
		}
		if (toRun.length > 0) {
			gs.info('PeriodicReviewUtils.dailyProcess: Running ' + toRun.length + ' execution(s) today.');
			for (var i in toRun) {
				var thisRun = toRun[i];
				this.processExecution(thisRun.configuration, thisRun.table, thisRun.filter);
			}
			gs.info('PeriodicReviewUtils.dailyProcess: ' + toRun.length + ' execution(s) completed.');
		} else {
			gs.info('PeriodicReviewUtils.dailyProcess: Nothing scheduled to run today.');
		}
	},

	processExecution: function(configuration, table, filter) {
		gs.info('PeriodicReviewUtils.dailyProcess: ' + configuration + '; ' + table + '; ' + filter);

		// fetch system properties
		var systemFallbackAddress = gs.getProperty('x_11556_periodic_r.fallback_email_address');
		var systemEmailDomain = gs.getProperty('x_11556_periodic_r.email_domain');

		// create execution record
		var executionGR = new GlideRecord('x_11556_periodic_r_review_execution');
		executionGR.configuration = configuration;
		executionGR.run_date = new GlideDate();
		executionGR.run_start = new GlideDateTime();
		executionGR.state = 'Running';
		executionGR.short_description = executionGR.getDisplayValue('run_date') + ' review notices';
		executionGR.total_items = 0;
		executionGR.total_notices = 0;
		executionGR.completion_code = 0;
		executionGR.insert();
		var configurationGR = executionGR.configuration.getRefRecord();
		var fallbackRecipient = configurationGR.getDisplayValue('fallback_recipient');

		// create temporary notice record
		var noticeGR = new GlideRecord('x_11556_periodic_r_review_notice');
		noticeGR.review_execution = executionGR.getUniqueValue();
		noticeGR.recipient = configurationGR.fallback_recipient;
		noticeGR.short_description = 'Temporary notice record';
		noticeGR.insert();
		var tempNotice = noticeGR.getUniqueValue();

		// create notice item records from source table data
		var noticeItemGR = new GlideRecord('x_11556_periodic_r_review_notice_item');
		var itemCt = 0;
		var sourceGR = new GlideRecord(table);
		if (sourceGR.isValid()) {
			if (filter) {
				sourceGR.addEncodedQuery(filter);
			}
			sourceGR.orderBy(configurationGR.short_description_column);
			sourceGR.query();
			while (sourceGR.next()) {
				noticeItemGR.initialize();
				noticeItemGR.review_notice = tempNotice;
				noticeItemGR.id = sourceGR.getUniqueValue();
				noticeItemGR.short_description =  sourceGR.getDisplayValue(configurationGR.short_description_column);
				noticeItemGR.description = sourceGR.getDisplayValue(configurationGR.description_column);
				noticeItemGR.recipient = sourceGR.getValue(configurationGR.recipient_column);
				noticeItemGR.insert();
				itemCt++;
				var recipientGR = new GlideRecord('sys_user');
				recipientGR.get(noticeItemGR.getValue('recipient'));
				var notes = '';
				if (recipientGR.isValid()) {
					if (recipientGR.getValue('active')) {
						var email = recipientGR.getDisplayValue('email');
						if (email) {
							if (systemEmailDomain && !email.endsWith(systemEmailDomain)) {
								notes = 'Recipient email address is not an authoized email address; reverting to fallback recipient';
							}
						} else {
							notes = 'Specified recipient has no email address; reverting to fallback recipient';
						}
					} else {
						notes = 'Specified recipient is not active; reverting to fallback recipient';
					}
				} else {
					notes = 'Recipient column empty on source record; reverting to fallback recipient';
				}
				if (notes) {
					noticeItemGR.notes = notes;
					noticeItemGR.recipient = fallbackRecipient;
					noticeItemGR.update();
				}
			}
			if (itemCt > 0) {
				executionGR.total_items = itemCt;
				executionGR.update();
				this.sendNotices(configurationGR, executionGR, noticeGR, noticeItemGR, tempNotice);
			} else {

				// finalize the execution
				executionGR.total_items = 0;
				executionGR.total_notices = 0;
				executionGR.run_end = new GlideDateTime();
				executionGR.state = 'Complete';
				executionGR.completion_code = 0;
				executionGR.description = 'No items matched the filter criteria during this run, so no notices were sent out.';
				executionGR.update();
			}

			// delete the temporary notice
			noticeGR.get(tempNotice);
			noticeGR.deleteRecord();
		} else {
			// finalize the execution
			executionGR.total_items = 0;
			executionGR.total_notices = 0;
			executionGR.run_end = new GlideDateTime();
			executionGR.state = 'Failed';
			executionGR.completion_code = 1;
			executionGR.description = 'The specified source table in the configuration record is not valid; execution cannot proceed; aborting execution.';
			executionGR.update();
		}

		// set the next run date
		configurationGR.setValue('next_scheduled_date', this.calculateNextRunDate(configurationGR));
		configurationGR.update();
	},

	calculateNextRunDate: function(configurationGR) {
		var runDate = new Date(configurationGR.getDisplayValue('next_scheduled_date'));
		var frequency = configurationGR.getValue('frequency');
		var days = 0;
		var months = 0;
		if (frequency == 'daily') {
			days = 1;
		} else if (frequency == 'weekly') {
			days = 7;
		} else if (frequency == 'biweekly') {
			days = 14;
		} else if (frequency == 'monthly') {
			months = 1;
		} else if (frequency == 'bimonthly') {
			months = 2;
		} else if (frequency == 'quarterly') {
			months = 3;
		} else if (frequency == 'semiannually') {
			months = 6;
		} else if (frequency == 'annually') {
			months = 12;
		} else if (frequency == 'biannually') {
			months = 24;
		}
		if (days > 0) {
			runDate.setDate(runDate.getDate() + days);
		} else {
			runDate.setMonth(runDate.getMonth() + months);
		}
		return JSON.stringify(runDate).substring(1, 11);
	},

    type: 'PeriodicReviewUtils'
};

Next time, we will stub out that sendNotices function and give things another test or two, and then actually build out that function to group the items by recipient and send out the notices.

Companion Widgets for SNH Data Tables, Part II

“If you set a good example you need not worry about setting rules.”
Lee Iacocca

Last time, we put together a simple companion widget for an SNH Data Table that contained a single button. Today we will try something a little more complicated, with three different buttons and three different bulk actions. For this example, we will create a list of records waiting for an approval decision from the perspective of the person who can make such a decision. All of our buttons and bulk actions will be related to passing judgement on the items listed.

My Approvals example page

Here is the configuration script that we will used to produce this table.

var MyApprovalsConfig = Class.create();
MyApprovalsConfig.prototype = Object.extendsObject(ContentSelectorConfig, {
	initialize: function() {
	},

	perspective: [{
		name: 'all',
		label: 'All',
		roles: ''
	}],

	state: [{
		name: 'all',
		label: 'All'
	}],

	table: {
		all: [{
			name: 'sysapproval_approver',
			displayName: 'Approval',
			all: {
				filter: 'approverDYNAMIC90d1921e5f510100a9ad2572f2b477fe^state=requested',
				fields: 'sysapproval.sys_class_name,sysapproval,sysapproval.short_description,sysapproval.opened_by',
				svcarray: [],
				aggarray: [],
				btnarray: [{
					name: 'approve',
					label: 'Approve',
					heading: 'Approve',
					icon: 'workflow-approved',
					color: 'success',
					hint: 'Click here to approve',
					page_id: '',
					condition: ''
				},{
					name: 'approvecmt',
					label: 'Approve w/Comments',
					heading: 'w/Comments',
					icon: 'comment-hollow',
					color: 'success',
					hint: 'Click here to approve with comments',
					page_id: '',
					condition: ''
				},{
					name: 'reject',
					label: 'Reject',
					heading: 'Reject',
					icon: 'workflow-rejected',
					color: 'danger',
					hint: 'Click here to reject',
					page_id: '',
					condition: ''
				}],
				refmap: {
					sys_user: 'user_profile'
				},
				actarray: [{
					name: 'approve',
					label: 'Approve'
				},{
					name: 'approvecmt',
					label: 'Approve w/Comments'
				},{
					name: 'reject',
					label: 'Reject'
				}]
			}
		}]
	},

	type: 'MyApprovalsConfig'
});

The buttons handle actions for individual items and the bulk actions perform the same functions, but for more than one item at a time. Clicking on a button that involves comments should bring up an input dialog.

Entering comments for the selected action

Once the comments are entered, the companion widget should go ahead and update the database and inform the user of the action taken.

The selected item has been approved

Once the list is refreshed to reflect the changes, we can demonstrate a similar process for bulk actions.

Selecting all records for a bulk action

Once again, a dialog appears so that the rejection reason can be entered.

Entering comments for the selected bulk action

And once the comments have been entered, the action is taken for all selected records.

Bulk action taken on all selected records

And once the action has been taken, the screen is again refreshed to reveal that there are no further items requiring approval decisions.

Empty list after all items have been resolved

So that’s the concept. Now let’s take a look the companion widget that makes this all work. To begin, we throw in the same list of event names at the top like we do with all of the others.

var eventNames = {
	referenceClick: 'data_table.referenceClick',
	aggregateClick: 'data_table.aggregateClick',
	buttonClick: 'data_table.buttonClick',
	bulkAction: 'data_table.bulkAction'
};

We have to deal with both buttons and bulk actions for this one, but since they both do essentially the same thing, it would be nice to share as much of the code as possible. Since the only difference is that the buttons work on a single record and the bulk actions work on a list of records, we could convert the single record for the button into a list of one, and then hand off the work to a function that expected a list, which would then work for both. We still need two listeners, though, so let’s build those next.

$rootScope.$on(eventNames.buttonClick, function(e, parms) {
	var action = parms.config.name;
	var sysId = [];
	sysId.push(parms.record.sys_id);
	processAction(action, sysId);
});

$rootScope.$on(eventNames.bulkAction, function(e, parms) {
	var action = parms.config.name;
	var sysId = [];
	for (var i=0; i<parms.record.length; i++) {
		sysId.push(parms.record[i].sys_id);
	}
	processAction(action, sysId);
});

Our processAction function then will not know or care whether the action came from a button or a bulk action. Everything from this point on will be the same either way. Let’s take a quick peek at that function now.

function processAction(action, sysId) {
	if (!c.data.inProgress) {
		c.data.inProgress = true;
		c.data.action = action;
		c.data.sys_id = sysId;
		c.data.comments = '';
		if (action == 'reject' || action == 'approvecmt') {
			getComments(action);
		} else if (action == 'approve') {
			processDecision();
		}
		c.data.inProgress = false;
	}
}

The check of c.data.inProgress is just a defensive mechanism to ensure that we are only processing one action at a time. We set it to true when we start and to false when we are done, and if it is already set to true when we start, then we do nothing, as there is already another action in progress. The rest is just a check to see if we need to collect comments, and if not, we proceed directly to processing the action. If we do need to collect comments, then we call this function.

function getComments(action) {
	var msg = 'Approval comments:';
	if (action == 'reject') {
		msg = 'Please enter the reason for rejection:';
	}
	spModal.prompt(msg, '').then(function(comments) {
		c.data.comments = comments;
		processDecision();
	});
}

And in either case, we end up here to process the decision.

function processDecision() {
	c.server.update().then(function(response) {
		window.location.reload(true);
	});
}

Basically, that just makes a call over to server side where the actual database updates take place. Here is the entire Client controller all put together.

api.controller = function($scope, $rootScope, $window, spModal) {
	var c = this;
	var eventNames = {
		referenceClick: 'data_table.referenceClick',
		aggregateClick: 'data_table.aggregateClick',
		buttonClick: 'data_table.buttonClick',
		bulkAction: 'data_table.bulkAction'
	};

	$rootScope.$on(eventNames.buttonClick, function(e, parms) {
		var action = parms.config.name;
		var sysId = [];
		sysId.push(parms.record.sys_id);
		processAction(action, sysId);
	});

	$rootScope.$on(eventNames.bulkAction, function(e, parms) {
		var action = parms.config.name;
		var sysId = [];
		for (var i=0; i<parms.record.length; i++) {
			sysId.push(parms.record[i].sys_id);
		}
		processAction(action, sysId);
	});

	function processAction(action, sysId) {
		if (!c.data.inProgress) {
			c.data.inProgress = true;
			c.data.action = action;
			c.data.sys_id = sysId;
			c.data.comments = '';
			if (action == 'reject' || action == 'approvecmt') {
				getComments(action);
			} else if (action == 'approve') {
				processDecision();
			}
			c.data.inProgress = false;
		}
	}

	function getComments(action) {
		var msg = 'Approval comments:';
		if (action == 'reject') {
			msg = 'Please enter the reason for rejection:';
		}
		spModal.prompt(msg, '').then(function(comments) {
			c.data.comments = comments;
			processDecision();
		});
	}

	function processDecision() {
		c.server.update().then(function(response) {
			window.location.reload(true);
		});
	}
};

The actual work of updating the approval records takes place over on the server side. Here is the complete Server script.

(function() {
	if (input && input.sys_id && input.sys_id.length > 0) {
		var total = 0;
		var approvalGR = new GlideRecord('sysapproval_approver');
		for (var i=0; i<input.sys_id.length; i++) {
			approvalGR.get(input.sys_id[i]);
			if (approvalGR.state == 'requested') {
				approvalGR.state = 'approved';
				if (input.action == 'reject') {
					approvalGR.state = 'rejected';
				}
				var comments = 'Approval response from ' + gs.getUserDisplayName() + ':';
				comments += '\n\nDecision: ' + approvalGR.getDisplayValue('state');
				if (input.comments) {
					comments += '\nReason: ' + input.comments;
				}
				approvalGR.comments = comments;
				if (approvalGR.update()) {
					total++;
				}
			}
		}
		if (total > 0) {
			var message = total + ' items ';
			if (total == 1) {
				message = 'One item ';
			}
			if (input.action == 'reject') {
				message += 'rejected.';
			} else {
				message += 'approved.';
			}
			gs.addInfoMessage(message);
		}
	}
})();

That’s all basic GlideRecord stuff, so there isn’t too much commentary to add beyond the actual code itself. And that’s all there is to that. I still want to bundle all of these example up into a little Update Set, but I think I will do one more example before we do that. We will take a look at that one next time out.

Collaboration Store, Part LXXXIII

“Many hands make light work.”
John Heywood

Last time, we built a little companion widget to share the page with our storefront widget to provide the ability to filter the list of applications displayed. Clicking on the search button on that widget reloads the page with the search criteria present in the query string of the URL. Now we need to modify the primary widget to pull that search criteria in from the URL and then use it when querying the database for applications to display. To begin, we can use basically the same code that we used in the search widget to bring in the values.

var search = $sp.getParameter('search');
var local = $sp.getParameter('local') == 'true';
var current = $sp.getParameter('current') == 'true';
var upgrade = $sp.getParameter('upgrade') == 'true';
var notins = $sp.getParameter('notins') == 'true';

Now that we have the information, we need to use it to filter the query results. For the search string, we want to look for that string in either the name or the description of the app. That one is relatively straightforward and can be handled with a single encoded query.

var appGR = new GlideRecord('x_11556_col_store_member_application');
if (search) {
	appGR.addEncodedQuery('nameLIKE' + search + '^ORdescriptionLIKE' + search);
}

The remaining parameters are all boolean values that relate to one another in some way, so we have to handle them as a group. First of all, if none of the boxes are checked or all of the boxes are checked, then there is no need for any filtering, so we can eliminate those cases right at the top.

if (local && current && upgrade && notins) {
		// everything checked -- no filter needed
} else if (!local && !current && !upgrade && !notins) {
	// nothing checked -- no filter needed
} else {
	...
}

After that, things get a little more complicated. Local apps are those where the provider instance is the local instance, and we don’t apply any other filter to that pool. You either want the local apps included or you do not. They are included by default, but if you start checking boxes then they are only included if you check the Local checkbox.

var query = '';
var separator = '';
if (local) {
	query += 'provider.instance=' + gs.getProperty('instance_name');
	separator = '^OR';
} else {
	query += separator + 'provider.instance!=' + gs.getProperty('instance_name');
	separator = '^';
}

The remaining three values relate to the apps pulled down from the store, which are classified based on whether they have been installed on the local instance (current and upgrade) or not (notins). Installed apps have a value in the application field and those that have not been installed do not. Additionally, installed apps are considered current if the installed version is the same as the current version; otherwise they are classified as having an upgrade available. We only need to check the version if you want one, but not the other. If you want both current and upgrade or neither current nor upgrade, then there is no point in making that distinction. So we first check both cases where the values are different.

if (current && !upgrade) {
	if (notins) {
		query += separator + 'applicationISEMPTY^ORversionSAMEASapplication.version';
	} else {
		query += separator + 'versionSAMEASapplication.version';
	}
} else if (!current && upgrade) {
	if (notins) {
		query += separator + 'applicationISEMPTY^ORversionNSAMEASapplication.version';
	} else {
		query += separator + 'versionNSAMEASapplication.version';
	}
...

And finally, we check the last two cases where current and upgrade are the same value.

} else if (current && upgrade && !notins) {
	query += separator + 'applicationISNOTEMPTY';
} else if (!current && !upgrade && notins) {
	query += separator + 'applicationISEMPTY';
}

That should do it. Putting that all together with the original fetchItemDetails function gives us this new version of that function.

function fetchItemDetails(items) {
	var search = $sp.getParameter('search');
	var local = $sp.getParameter('local') == 'true';
	var current = $sp.getParameter('current') == 'true';
	var upgrade = $sp.getParameter('upgrade') == 'true';
	var notins = $sp.getParameter('notins') == 'true';
	var appGR = new GlideRecord('x_11556_col_store_member_application');
	if (search) {
		appGR.addEncodedQuery('nameLIKE' + search + '^ORdescriptionLIKE' + search);
	}
	if (local && current && upgrade && notins) {
		// everything checked -- no filter needed
	} else if (!local && !current && !upgrade && !notins) {
		// nothing checked -- no filter needed
	} else {
		var query = '';
		var separator = '';
		if (local) {
			query += 'provider.instance=' + gs.getProperty('instance_name');
			separator = '^OR';
		} else {
			query += separator + 'provider.instance!=' + gs.getProperty('instance_name');
			separator = '^';
		}
		if (current && !upgrade) {
			if (notins) {
				query += separator + 'applicationISEMPTY^ORversionSAMEASapplication.version';
			} else {
				query += separator + 'versionSAMEASapplication.version';
			}
		} else if (!current && upgrade) {
			if (notins) {
				query += separator + 'applicationISEMPTY^ORversionNSAMEASapplication.version';
			} else {
				query += separator + 'versionNSAMEASapplication.version';
			}
		} else if (current && upgrade && !notins) {
			query += separator + 'applicationISNOTEMPTY';
		} else if (!current && !upgrade && notins) {
			query += separator + 'applicationISEMPTY';
		}
		appGR.addEncodedQuery(query);
	}
	appGR.orderBy('name');
	appGR.query();
	while (appGR.next()) {
		var item = {};
		item.name = appGR.getDisplayValue('name');
		item.description = appGR.getDisplayValue('description');
		item.logo = appGR.getValue('logo');
		item.version = appGR.getDisplayValue('current_version');
		item.provider = appGR.getDisplayValue('provider.name');
		item.providerLogo = appGR.provider.getRefRecord().getValue('logo');
		item.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
		item.sys_id = appGR.getUniqueValue();
		item.state = 0;
		if (appGR.getValue('application')) {
			item.state = 1;
			item.installedVersion = appGR.getDisplayValue('application.version');
			if (item.version == item.installedVersion) {
				item.state = 2;
			}
		}
		if (!item.local && item.state != 2) {
			item.attachmentId = getAttachmentId(item.sys_id, item.version);
		}
		items.push(item);
	}
}

Now we can fire up the storefront page and start clicking around and see what we have. Or better yet, we can push out yet another Update Set and let all of you following along at home click around and see if everything works as it should. I always like it when folks with a little different perspective take the time to pull stuff down and give it a whirl, so here you go:

If this is your first time, you will want to take a peek here and here and here. For the rest of you, this is just another 0.7.x drop-in replacement, and you should know what to do by now. Please let us all know what you find in the comments below. Feedback is always welcome and always very much appreciated. Hopefully, we will get some interesting results and we can take a look at those next time out.

Scripted Value Columns, Part V

“Make incremental progress; change comes not by the yard, but by the inch.”
Rick Pitino

Last time, we had enough parts cobbled together to demonstrate that the concept actually works. Of course, all we had to show for it was some random numbers, but that told us that the specified script was being called for each row, which is what we were after. Now that we know that the basic structure is performing as desired, we can revisit the configurable Script Include component and see if we can come up with some actual use cases that might be of value to someone.

One of the questions that triggered this idea was related to comments and work notes on Incidents. Assuming that the main record in the table is an Incident, we can clone our example Script Include to create one dedicated to pulling data out of the latest comment or work note on an Incident. We can call this new Script Include ScriptedJournalValueProvider.

var ScriptedJournalValueProvider = Class.create();
ScriptedJournalValueProvider.prototype = {
	initialize: function() {
	},

	getScriptedValue: function(item, config) {
		return Math.floor(Math.random() * 100) + '';
	},

	type: 'ScriptedJournalValueProvider'
};

We will want to delete the example code in the getScriptedValue function and come up with our own, but other than that, the basic structure remains the same. Assuming that we want our script to be able to handle a number of attributes of an Incident Journal entry, we can use the name of the column to determine which function will fetch us our value.

getScriptedValue: function(item, config) {
	var response = '';

	var column = config.name;
	if (column == 'last_comment') {
		response = this.getLastComment(item, config);
	} else if (column == 'last_comment_by') {
		response = this.getLastCommentBy(item, config);
	} else if (column == 'last_comment_on') {
		response = this.getLastCommentOn(item, config);
	} else if (column == 'last_comment_type') {
		response = this.getLastCommentType(item, config);
	}

	return response;
}

This way, we can point to this same script in multiple columns and the name of the column will determine which value from the last comment or work note gets returned.

Since all of the functions will need the data for the last entry, we should create a shared function that they all can leverage to obtain the record. As with many things on the ServiceNow platform, there are a number of ways to go about this, but for our demonstration purposes, we will read the sys_journal_field table looking for the last entry for the Incident in the current row.

getLastJournalEntry: function(sys_id) {
	var journalGR = new GlideRecord('sys_journal_field');
	journalGR.orderByDesc('sys_created_on');
	journalGR.addQuery('name', 'incident');
	journalGR.addQuery('element_id', sys_id);
	journalGR.setLimit(1);
	journalGR.query();
	journalGR.next();
	return journalGR;
}

Now that we have a common way to obtain the GlideRecord for the latest entry, we can start building our functions that extract the requested data value. Here is the one for the comment text.

getLastComment: function(item, config) {
	var response = '';

	var journalGR = this.getLastJournalEntry(item.sys_id);
	if (journalGR.isValidRecord()) {
		response = journalGR.getDisplayValue('value');
	}

	return response;
}

The others will basically be copies of the above, modified to return different values based on their purpose. The whole thing, all put together, now looks like this.

var ScriptedJournalValueProvider = Class.create();
ScriptedJournalValueProvider.prototype = {
	initialize: function() {
	},

	getScriptedValue: function(item, config) {
		var response = '';

		var column = config.name;
		if (column == 'last_comment') {
			response = this.getLastComment(item, config);
		} else if (column == 'last_comment_by') {
			response = this.getLastCommentBy(item, config);
		} else if (column == 'last_comment_on') {
			response = this.getLastCommentOn(item, config);
		} else if (column == 'last_comment_type') {
			response = this.getLastCommentType(item, config);
		}

		return response;
	},

	getLastComment: function(item, config) {
		var response = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			response = journalGR.getDisplayValue('value');
		}

		return response;
	},

	getLastCommentBy: function(item, config) {
		var response = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			response = journalGR.getDisplayValue('sys_created_by');
		}

		return response;
	},

	getLastCommentOn: function(item, config) {
		var response = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			response = journalGR.getDisplayValue('sys_created_on');
		}

		return response;
	},

	getLastCommentType: function(item, config) {
		var response = '';

		var journalGR = this.getLastJournalEntry(item.sys_id);
		if (journalGR.isValidRecord()) {
			response = journalGR.getDisplayValue('element');
		}

		return response;
	},

	getLastJournalEntry: function(sys_id) {
		var journalGR = new GlideRecord('sys_journal_field');
		journalGR.orderByDesc('sys_created_on');
		journalGR.addQuery('name', 'incident');
		journalGR.addQuery('element_id', sys_id);
		journalGR.setLimit(1);
		journalGR.query();
		journalGR.next();
		return journalGR;
	},

	type: 'ScriptedJournalValueProvider'
};

Now that we have a Script Include to utilize, we need to put together a new page so that we can configure it to make use of it so that we can test it out. Let’s make a quick copy of the page that we were using for testing last time and call it scripted_value_test. Also, let’s make a quick copy of the test configuration script that we were using earlier and call it ScriptedValueConfig.

var ScriptedValueConfig = Class.create();
ScriptedValueConfig.prototype = Object.extendsObject(ContentSelectorConfig, {
	initialize: function() {
	},

	perspective: [{
		name: 'all',
		label: 'all',
		roles: ''
	}],

	state: [{
		name: 'all',
		label: 'All'
	}],

	table: {
		all: [{
			name: 'incident',
			displayName: 'Incident',
			all: {
				filter: 'caller_idDYNAMIC90d1921e5f510100a9ad2572f2b477fe^active=true',
				fields: 'number,opened_by,opened_at,short_description',
				svcarray: [{
					name: 'last_comment_on',
					label: 'Last Comment',
					heading: 'Last Comment',
					script: 'global.ScriptedJournalValueProvider'
				},{
					name: 'last_comment_by',
					label: 'Last Comment By',
					heading: 'Last Comment By',
					script: 'global.ScriptedJournalValueProvider'
				}],
				aggarray: [],
				btnarray: [],
				refmap: {
					sys_user: 'user_profile'
				},
				actarray: []
			}
		}]
	},

	type: 'ScriptedValueConfig'
});

Now let’s pull up our new page in the Service Portal Designer and point the table widget to our new configuration script.

Configuring the new test page to use the new test configuration script

Once we save that, we can pop over to the Service Portal and pull up our new page to try it out.

First test of our first real world utilization of this feature

Beautiful! Our new scripted value provider Script Include was called by the core SNH Data Table widget and it returned the requested values, which were then displayed on the list with all of the other standard table columns. That wasn’t so hard, now was it?

Of course, we still have a couple more wrapper widgets to modify (and test!), and I would like to produce another example, maybe something to do with catalog item variables, but I think we are close. One thing I see that I never noticed before, though, is that the added columns don’t quite line up with the original columns. Maybe it is a CSS thing, or maybe it is something a little more diabolical, but I want to take a look at that and see what is going on there. All of the data in the columns should be displayed consistently; I don’t like it when things don’t all line up correctly. I need to figure out what is going on there and see what I can do about it.

Anyway, we still have a little more work to do before we can wrap this all up into a new Update Set and post a new version out on Share, but we will keep plugging along in our next installment.

Collaboration Store, Part LXX

“Software bugs are like cockroaches; there are probably dozens hiding in difficult to reach places for every one you find and fix.”
Donald G. Firesmith

Last time, we went through the list of issues that have been reported so far, the biggest one being the fact that the REST API call to the Host instance is sending over the application logo image attachment instead of the Update Set XML file attachment. Since then, we have received some additional information in the form of the data logged to the REST API log file. Here is the entry of interest:

{
	“size_bytes”: “547670”,
	“file_name”: “logo”,
	“sys_mod_count”: “0”,
	“average_image_color”: “”,
	“image_width”: “”,
	“sys_updated_on”: “2022-08-02 16:55:55”,
	“sys_tags”: “”,
	“table_name”: “x_11556_col_store_member_application_version”,
	“sys_id”: “c227acc297855110b40ebde3f153aff3”,
	“image_height”: “”,
	“sys_updated_by”: “csworker1.dev69362”,
	“download_link”: “https://dev69362.service-now.com/api/now/attachment/c227acc297855110b40ebde3f153aff3/file”,
	“content_type”: “image/jpeg”,
	“sys_created_on”: “2022-08-02 16:55:55”,
	“size_compressed”: “247152”,
	“compressed”: “true”,
	“state”: “pending”,
	“table_sys_id”: “b127a88297855110b40ebde3f153afa6”,
	“chunk_size_bytes”: “700000”,
	“hash”: “8b5a07a6c0edf042df4b3c24e729036562985b705427ba7e33768566de94e96f”,
	“sys_created_by”: “csworker1.dev69362”
}

If you look at the table_name property, you can see that it is attaching something to the version record, and if you look at the file_name and content_type properties, you can see that it isn’t the Update Set XML file that it is sending over. So let’s take a look at the shared code that sends over the Update Set XML file attachment and see if we can see where things may have gone wrong.

pushAttachment: function(attachmentGR, targetGR, remoteVerId) {
	var result = {};

	var gsa = new GlideSysAttachment();
	result.url = 'https://';
	result.url += targetGR.getDisplayValue('instance');
	result.url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
	result.url += remoteVerId;
	result.url += '&file_name=';
	result.url += attachmentGR.getDisplayValue('file_name');
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', attachmentGR.getDisplayValue('content_type'));
	request.setRequestHeader('Accept', 'application/json');
	request.setRequestBody(gsa.getContent(attachmentGR));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	}
	this.logRESTCall(targetGR, result);

	return result;
}

By this point in the process, the GlideRecord for the attachment has already been obtained from the database, so the problem has to be upstream from here. This is a shared function called from many places, but our problem is related to the application publishing process, so let’s take a look at the ApplicationPublisher Script Include and see if we can find where this function is called.

processPhase7: function(answer) {
	var gsa = new GlideSysAttachment();
	var attachmentGR = new GlideRecord('sys_attachment');
	if (attachmentGR.get(answer.attachmentId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		var result = csu.pushAttachment(attachmentGR, targetGR, answer.hostVerId);
		if (result.error) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + result.error_code + ' - ' + result.error_message);
		} else if (result.parse_error) {
			answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + result.body);
		} else if (result.status != 200 && result.status != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + result.status);
		} else {
			answer.hostVerId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid attachment record sys_id: ' + answer.attachmentId);
	}

	return answer;
}

Here we are fetching the attachment record based on the sys_id in the answer object property called attachmentId. There isn’t much opportunity for things to go tango uniform with this particular code, so I think we have to assume that somewhere upstream of this logic the value of answer.attachmentId got set to the sys_id of the logo attachment instead of the sys_id of the Update Set XML file attachment. So it looks like we need to do a quick search for answer.attachmentId and see where this property may have gotten corrupted.

Since the version record does not yet exist when the Update Set XML file is generated, it is initially attached to the stock application record. Then, once the version record has been created, the attachment is copied from the application record to the version record, and then the original attachment file is removed from the stock application record. All of that seems to work, since the Update Set XML file is, in fact, attached to the version record on the original source instance; however, somewhere along the line, the sys_id of that attachment record in the answer object ends up being the sys_id of the logo image attachment record. Let’s take a look at that code.

processPhase4: function(answer) {
	var gsa = new GlideSysAttachment();
	var values = gsa.copy('sys_app', answer.appSysId, 'x_11556_col_store_member_application_version', answer.versionId);
	gsa.deleteAttachment(answer.attachmentId);
	if (values.length > 0) {
		var ids = values[values.length - 1].split(',');
		if (ids[1]) {
			answer.attachmentId = ids[1];
		} else {
			answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' + JSON.stringify(values));
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' +  JSON.stringify(values));
	}

	return answer;
}

This has to be the source of the problem. The copy method the GlideSysAttachment object doesn’t allow you to select what to copy; it arbitrarily copies all attachments from one record to another and returns an array of sys_id pairs (before and after for each attachment). The code above assumed that the last pair contained the sys_id that we were looking for, but apparently, that is not always the case. It looks like we need to examine every sys_id pair in the array, select the one that contains the XML file, grab that sys_id, and then delete all of the other attachments from the version record. That would mean replacing this:

var ids = values[values.length - 1].split(',');
if (ids[1]) {
	answer.attachmentId = ids[1];
}

… with this:

var origId = answer.attachmentId;
for (var i=0; i<values.length; i++) {
	var ids = values[i].split(',');
	if (ids[0] == origId) {
		answer.attachmentId = ids[1];
		gsa.deleteAttachment(origId);
	} else {
		gsa.deleteAttachment(ids[1]);
	}
}

Basically, this code loops through all of the sys_id pairs, looks for the one where the first sys_id matches the original, grabs the second sys_id of that pair for the new answer.attachmentId value, and then deletes the original attachment record. When the first sys_id does not match, then it deletes the copied attachment from the version record, as we did not want to copy that one anyway. We will have to do a little testing to prove this out, but hopefully this will resolve this issue.

Next time, we should have a new Update Set available with this, and a few other, minor corrections in it, and then we can do a little retesting and see if that resolves a few of these issues. As always, if anyone finds anything else that we need to address, please leave the details in the comments section below. All feedback is heartily welcomed!

Collaboration Store, Part LXIV

“Optimism is an occupational hazard of programming: feedback is the treatment.”
Kent Beck

Last time, we wrapped up the last of the refactoring for all of the features that push artifacts from one instance to another. Although that covers the majority of the REST API calls, there are still a few remaining functions that make REST API calls of their own, and we want to have those calls logged just like all of the others in the shared functions. The first of those is the getStoreInfo function in the CollaborationStoreUtils Script Include.

getStoreInfo: function(host) {
	var result = {};

	var request  = new sn_ws.RESTMessageV2();
	request.setHttpMethod('get');
	request.setEndpoint('https://' + host + '.service-now.com/api/x_11556_col_store/v1/info');
	var response = request.execute();
	result.responseCode = response.getStatusCode();
	if (response.haveError()) {
		result.error = response.getErrorMessage();
		result.errorCode = response.getErrorCode();
		result.body = response.getBody();
	} else if (result.responseCode == '200') {
		result.storeInfo = JSON.parse(response.getBody());
		if (result.storeInfo.result.status == 'success') {
			result.name = result.storeInfo.result.info.name;
			var csgu = new global.CollaborationStoreGlobalUtils();
			csgu.setProperty('x_11556_col_store.active_token', result.storeInfo.result.info.sys_id);
		} else {
			result.error = 'This instance is not a Host instance';
		}
	} else {
		result.error = 'Invalid HTTP Response Code: ' + result.responseCode;
		result.body = response.getBody();
	}

	return result;
}

It shouldn’t be too difficult to rework the code a little bit to adopt the standard result object that the logging function is expecting. The main problem with this particular function is timing: we need to pass the GlideRecord of the target instance to the logging function, but we are calling the getStoreInfo function so that we can get the data needed to create the GlideRecord for the Host instance. At the moment that we are making the call, the GlideRecord for the Host instance does not yet exist. Since we do not yet have a Host instance GlideRecord to pass, we will have to pass null, but we will also have to modify the logging function to handle that possibility. Here is the refactored getStoreInfo function:

getStoreInfo: function(host) {
	var result = {};

	result.url = 'https://' + host + '.service-now.com/api/x_11556_col_store/v1/info';
	result.method = 'GET';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	} else if (result.obj) {
		if (result.obj.result.status == 'success') {
			result.name = result.obj.result.info.name;
			var csgu = new global.CollaborationStoreGlobalUtils();
			csgu.setProperty('x_11556_col_store.active_token', result.obj.result.info.sys_id);
		} else {
			result.error = true;
			result.error_code = '99';
			result.error_message = 'This instance is not a Host instance';
		}
	}
	this.logRESTCall(null, result);

	return result;
}

To avoid a null pointer exception in the logging function, we need to add a check for the target instance GlideRecord before we attempt to snag its sys_id.

logRESTCall: function (targetGR, result, payload) {
	var logGR = new GlideRecord('x_11556_col_store_rest_api_log');
	if (targetGR) {
		logGR.instance = targetGR.getUniqueValue();
	}
	...
}

Finally, to correct the log records once the Host instance record has been created in the set-up process, we can call this simple function:

function fixLogRecords(targetGR) {
	var logGR = new GlideRecord('x_11556_col_store_rest_api_log');
	logGR.addQuery('instance', null);
	logGR.query();
	while (logGR.next()) {
		logGR.instance = targetGR.getUniqueValue();
		logGR.update();
	}
}

Basically, it just looks for any log records that do not have a target instance value and updates them with the new Host instance record’s sys_id. That should take care of that.

There is yet another REST API call made before the Host record is created and that one is in the registerWithHost function. Here is the current version:

registerWithHost: function(mbrGR) {
	var result = {};

	this.createUpdateWorker(mbrGR.getUniqueValue());
	var host = gs.getProperty('x_11556_col_store.host_instance');
	var token = gs.getProperty('x_11556_col_store.active_token');
	var payload = {};
	payload.sys_id = mbrGR.getUniqueValue();
	payload.name = mbrGR.getDisplayValue('name');
	payload.instance = mbrGR.getDisplayValue('instance');
	payload.email = mbrGR.getDisplayValue('email');
	payload.description = mbrGR.getDisplayValue('description');
	var request = new sn_ws.RESTMessageV2();
	request.setHttpMethod('post');
	request.setBasicAuth(this.WORKER_ROOT + host, token);
	request.setRequestHeader("Accept", "application/json");
	request.setEndpoint('https://' + host + '.service-now.com/api/x_11556_col_store/v1/register');
	request.setRequestBody(JSON.stringify(payload));
	var response = request.execute();
	result.responseCode = response.getStatusCode();
	result.bodyText = response.getBody();
	try {
		result.body = JSON.parse(response.getBody());
	} catch(e) {
		//
	}
	if (response.getErrorCode()) {
		result.error = response.getErrorMessage();
		result.errorCode = response.getErrorCode();
	} else if (result.responseCode != '202') {
		result.error = 'Invalid HTTP Response Code: ' + result.status;
	} else {
		mbrGR.accepted = new GlideDateTime();
		mbrGR.update();
	}

	return result;
}

Once again, we will need to rework this a little bit to adopt the standard result object that the logging function is expecting, and will have to pass null for the target instance GlideRecord, as that record has still not been created at this point in the process.

registerWithHost: function(mbrGR) {
	var result = {};

	this.createUpdateWorker(mbrGR.getUniqueValue());
	var payload = {};
	payload.sys_id = mbrGR.getUniqueValue();
	payload.name = mbrGR.getDisplayValue('name');
	payload.instance = mbrGR.getDisplayValue('instance');
	payload.email = mbrGR.getDisplayValue('email');
	payload.description = mbrGR.getDisplayValue('description');
	var host = gs.getProperty('x_11556_col_store.host_instance');
	result.url = 'https://' + host + '.service-now.com/api/x_11556_col_store/v1/register';
	result.method = 'POST';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + host, gs.getProperty('x_11556_col_store.active_token'));
	request.setRequestHeader("Accept", "application/json");
	request.setRequestBody(JSON.stringify(payload));
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	} else if (result.status != '202') {
		result.error = true;
		result.error_code = result.status;
		result.error_message = 'Invalid HTTP Response Code: ' + result.status;
	} else {
		mbrGR.accepted = new GlideDateTime();
		mbrGR.update();
	}
	this.logRESTCall(null, result);

	return result;
}

That should take care of all of the REST API calls in the CollaborationStoreUtils Script Include. There were never any REST API calls in the ApplicationInstaller Script Include, and we just removed all of the REST API calls in the ApplicationPublishers Script Include, but there are still some remaining in the InstanceSyncUtils, so we will need to take a look at those. That looks like a little bit of an effort, though, so let’s save that for our next installment.

Collaboration Store, Part LXII

“Now I’m a pretty lazy person and am prepared to work quite hard in order to avoid work.”
Martin Fowler

Last time, we modified the processPhase5 function in the ApplicationPublisher Script Include to use the shared functions for making REST API calls instead of its own code. Now we need to continue with that work and do the same for the processPhase6 and processPhase7 functions. Here is the current script for the processPhase6 function.

processPhase6: function(answer) {
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	if (versionGR.get(answer.versionId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var payload = {};
		payload.member_application = answer.hostAppId;
		payload.version = versionGR.getDisplayValue('version');
		payload.built_on = versionGR.getDisplayValue('built_on');
		var request  = new sn_ws.RESTMessageV2();
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader("Accept", "application/json");
		request.setHttpMethod('post');
		request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application_version');
		request.setRequestBody(JSON.stringify(payload, null, '\t'));
		response = request.execute();
		if (response.haveError()) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
		} else {
			jsonString = response.getBody();
			jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + jsonString);
			}
			if (!answer.error) {
				answer.hostVerId = jsonObject.result.sys_id;
			}
		}
	} else {
		answer = this.processError(answer, 'Invalid version record sys_id: ' + answer.versionId);
	}

	return answer;
}

The corresponding shared function is pushVersion, which takes as arguments the version GlideRecord, the target instance GlideRecord, and the sys_id of the application record on the target system. We are already fetching the version GlideRecord, we can use the function that we built last time to go get the target instance GlideRecord, and the sys_id of the application record on the target system was stored in the shared answer object in phase 5, so we should have everything that we need to invoke the appropriate shared function and check the results.

processPhase6: function(answer) {
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	if (versionGR.get(answer.versionId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		var result = csu.pushVersion(versionGR, targetGR, answer.hostAppId);
		if (result.error) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + result.error_code + ' - ' + result.error_message);
		} else if (result.parse_error) {
			answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + result.body);
		} else if (result.status != 200 && result.status != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + result.status);
		} else {
			answer.hostVerId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid version record sys_id: ' + answer.versionId);
	}

	return answer;
}

That should take care of the processPhase6 function. Now, let’s take a look at that processPhase7 function.

processPhase7: function(answer) {
	var gsa = new GlideSysAttachment();
	var sysAttGR = new GlideRecord('sys_attachment');
	if (sysAttGR.get(answer.attachmentId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var url = 'https://';
		url += host;
		url += '.service-now.com/api/now/attachment/file?table_name=x_11556_col_store_member_application_version&table_sys_id=';
		url += answer.hostVerId;
		url += '&file_name=';
		url += sysAttGR.getDisplayValue('file_name');
		var request  = new sn_ws.RESTMessageV2();
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader('Content-Type', sysAttGR.getDisplayValue('content_type'));
		request.setRequestHeader('Accept', 'application/json');
		request.setHttpMethod('post');
		request.setEndpoint(url);
		request.setRequestBody(gsa.getContent(sysAttGR));
		response = request.execute();
		if (response.haveError()) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
		}
	} else {
		answer = this.processError(answer, 'Invalid attachment record sys_id: ' + answer.attachmentId);
	}

	return answer;
}

The corresponding shared function is pushAttachment, which takes as arguments the attachment GlideRecord, the target instance GlideRecord, and the sys_id of the version record on the target system. Once again, we are already fetching the attachment GlideRecord, we can use the function that we built last time to go get the target instance GlideRecord, and the sys_id of the version record on the target system was stored in the shared answer object in phase 6, so once again we should have everything that we need to invoke the appropriate shared function and check the results.

processPhase7: function(answer) {
	var gsa = new GlideSysAttachment();
	var attachmentGR = new GlideRecord('sys_attachment');
	if (attachmentGR.get(answer.attachmentId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		var result = csu.pushAttachment(attachmentGR, targetGR, answer.hostVerId);
		if (result.error) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + result.error_code + ' - ' + result.error_message);
		} else if (result.parse_error) {
			answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + result.body);
		} else if (result.status != 200 && result.status != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + result.status);
		} else {
			answer.hostVerId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid attachment record sys_id: ' + answer.attachmentId);
	}

	return answer;
}

So that wraps up all of the refactoring for the application publishing process. All that is left to do now is to do the same thing for the application distribution process, where the Host instance sends out the artifacts for new application versions to all of the other Client instances in the community. We’ll jump right into that next time out.

Collaboration Store, Part LXI

“I think it is often easier to make progress on mega-ambitious dreams. Since no one else is crazy enough to do it, you have little competition.”
Larry Page

Last time, we wrapped up all of the code in the shared functions to include the logo images whenever an instance or application is transferred from one instance to another. Now we need to take a look at those places where the shared functions are not currently being used and replace the code in those existing functions with calls to the shared functions. This will not only consolidate the code and ensure that the logo images will be included, but since we also added a logging feature to the shared functions, it will also ensure that the REST API activity gets recorded.

Our ApplicationPublisher Script Include contains individual functions for the 7 independent phases of the application publishing process. The first four are all internal, but the last three move the artifacts from the Client instance to the the Host, so we will want to rework each one of those. Before we do that, though, we will want to snag the image from the sys_app record and attach it to our application record, which is something that we can do in Phase 2, once the application record is available. We can insert a line into this code:

mbrAppGR.setValue('name', sysAppGR.getValue('name'));
mbrAppGR.setValue('scope', sysAppGR.getValue('scope'));
mbrAppGR.setValue('description', sysAppGR.getValue('short_description'));
mbrAppGR.setValue('current_version', sysAppGR.getValue('version'));
mbrAppGR.setValue('active', true);
mbrAppGR.update();

… and make it look like this:

mbrAppGR.setValue('name', sysAppGR.getValue('name'));
mbrAppGR.setValue('scope', sysAppGR.getValue('scope'));
mbrAppGR.setValue('description', sysAppGR.getValue('short_description'));
mbrAppGR.setValue('current_version', sysAppGR.getValue('version'));
mbrAppGR.setValue('active', true);
if (sysAppGR.getValue('logo') && !mbrAppGR.getValue('logo')) {
	mbrAppGR.setValue('logo', this.copyLogoImage(answer));
}
mbrAppGR.update();

Of course, now that we have done that, we will need to build a new copyLogoImage function. We already have a function that copies an attachment (Phase 4), and we can steal most of the code from that guy.

processPhase4: function(answer) {
	var gsa = new GlideSysAttachment();
	var values = gsa.copy('sys_app', answer.appSysId, 'x_11556_col_store_member_application_version', answer.versionId);
	gsa.deleteAttachment(answer.attachmentId);
	if (values.length > 0) {
		var ids = values[values.length - 1].split(',');
		if (ids[1]) {
			answer.attachmentId = ids[1];
		} else {
			answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' + values);
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from attachment copy: ' + values);
	}

	return answer;
}

We want to return the attachment sys_id and not the answer object, and we need to swap out the table names, but other than that, it looks pretty similar:

copyLogoImage: function(answer) {
	var logoId = '';

	var gsa = new GlideSysAttachment();
	var values = gsa.copy('ZZ_YYsys_app', answer.appSysId, 'ZZ_YYx_11556_col_store_member_application', answer.mbrAppId);
	if (values.length > 0) {
		var ids = values[values.length - 1].split(',');
		if (ids[1]) {
			logoId = ids[1];
		} else {
			answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' + values);
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' + values);
	}

	return logoId;
}

That will get the logo image from the Scoped Application attached to our application record during the application publishing process. Now we need to take a look at those functions that send the local artifacts over to the Host instance and see if we can alter them to use the newly modified shared function. The first of such functions is processPhase5, and it moves over the application record.

processPhase5: function(answer) {
	var mbrAppGR = new GlideRecord('x_11556_col_store_member_application');
	if (mbrAppGR.get(answer.mbrAppId)) {
		var host = gs.getProperty('x_11556_col_store.host_instance');
		var token = gs.getProperty('x_11556_col_store.active_token');
		var thisInstance = gs.getProperty('instance_name');
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setBasicAuth(this.WORKER_ROOT + host, token);
		request.setRequestHeader("Accept", "application/json");
		request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=sys_id&sysparm_query=provider.instance%3D' + thisInstance + '%5Ename%3D' + encodeURIComponent(mbrAppGR.getDisplayValue('name')));
		var response = request.execute();
		if (response.haveError()) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() == '200') {
			var jsonString = response.getBody();
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + jsonString);
			}
			if (!answer.error) {
				var payload = {};
				payload.name = mbrAppGR.getDisplayValue('name');
				payload.scope = mbrAppGR.getDisplayValue('scope');
				payload.description = mbrAppGR.getDisplayValue('description');
				payload.current_version = mbrAppGR.getDisplayValue('current_version');
				payload.active = 'true';
				request  = new sn_ws.RESTMessageV2();
				request.setBasicAuth(this.WORKER_ROOT + host, token);
				request.setRequestHeader("Accept", "application/json");
				if (jsonObject.result && jsonObject.result.length > 0) {
					answer.hostAppId = jsonObject.result[0].sys_id;
					request.setHttpMethod('put');
					request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application/' + answer.hostAppId);
				} else {
					request.setHttpMethod('post');
					request.setEndpoint('https://' + host + '.service-now.com/api/now/table/x_11556_col_store_member_application');
					payload.provider = thisInstance;
				}
				request.setRequestBody(JSON.stringify(payload, null, '\t'));
				response = request.execute();
				if (response.haveError()) {
					answer = this.processError(answer, 'Error returned from Host instance: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
				} else if (response.getStatusCode() != 200 && response.getStatusCode() != 201) {
					answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
				} else {
					jsonString = response.getBody();
					jsonObject = {};
					try {
						jsonObject = JSON.parse(jsonString);
					} catch (e) {
						answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + jsonString);
					}
					if (!answer.error) {
						answer.hostAppId = jsonObject.result.sys_id;
					}
				}
			}
		} else {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + response.getStatusCode());
		}
	} else {
		answer = this.processError(answer, 'Invalid Member Application sys_id: ' + answer.appSysId);
	}

	return answer;
}

The corresponding shared function is pushApplication in the CollaborationStoreUtils Script Include, which takes the application GlideRecord, the target instance GlideRecord, and the sys_id of the local instance on the target instance as arguments. We already have the application GlideRecord, but will need to fetch the GlideRecord for the Host instance and grab the sys_id of local instance on the Host instance before we can make the call. Assuming that we can create a couple of functions to gather up the information that we need, we can reduce the new processPhase5 function to this:

processPhase5: function(answer) {
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	if (applicationGR.get(answer.mbrAppId)) {
		var targetGR = this.getHostInstanceGR();
		var csu = new CollaborationStoreUtils();
		answer.hostInstanceId = csu.getRemoteInstanceSysId(targetGR);
		var result = csu.pushApplication(applicationGR, targetGR, answer.hostInstanceId);
		if (result.error) {
			answer = this.processError(answer, 'Error returned from Host instance: ' + result.error_code() + ' - ' + result.error_message);
		} else if (result.parse_error) {
			answer = this.processError(answer, 'Unparsable JSON string returned from Host instance: ' + result.body);
		} else if (result.status != 200 && result.status != 201) {
			answer = this.processError(answer, 'Invalid HTTP Response Code returned from Host instance: ' + result.status);
		} else {
			answer.hostAppId = result.obj.result.sys_id;
		}
	} else {
		answer = this.processError(answer, 'Invalid Member Application sys_id: ' + answer.mbrAppId);
	}

	return answer;
}

That simplifies the code quite a bit, and yet we will be doing more work, as we will be moving over the logo image and also logging all the REST API calls. Much better. Of course we still need to build out those function to gather up the required arguments, but those should both be fairly straightforward. To fetch the Host instance GlideRecord, I kept the function in the ApplicationPublisher Script Include, but I put the other one in the main CollaborationStoreUtils Script Include, as that involves another REST API call to the Host instance, and I want to keep all of the functions that do that together in the same place. Here is the getHostInstanceGR function in the ApplicationPublisher Script Include:

getHostInstanceGR: function() {
	var instanceGR = new GlideRecord('x_11556_col_store_member_organization');
	instanceGR.get('instance', gs.getProperty('x_11556_col_store.host_instance'));
	return instanceGR;
}

And here is the getRemoteInstanceSysId function in the CollaborationStoreUtils Script Include:

getRemoteInstanceSysId: function(targetGR) {
	var sysId = '';

	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=sys_id&sysparm_query=instance%3D' + gs.getProperty('instance_name');
	result.method = 'GET';
	var request = new sn_ws.RESTMessageV2();
	request.setEndpoint(result.url);
	request.setHttpMethod(result.method);
	request.setBasicAuth(this.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader('Content-Type', 'application/json');
	request.setRequestHeader('Accept', 'application/json');
	var response = request.execute();
	result.status = response.getStatusCode();
	result.body = response.getBody();
	if (result.body) {
		try {
			result.obj = JSON.parse(result.body);
		} catch (e) {
			result.parse_error = e.toString();
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
	} else if (result.obj && result.obj.result && result.obj.result.length > 0) {
		sysId = result.obj.result[0].sys_id;
	}
	this.logRESTCall(targetGR, result);

	return sysId;
}

So that takes care of the first of the three functions that need to modified to use the shared REST API functions. Now we just need to do the same thing for the other two, processPhase6 and processPhase7. That should be a little simpler now that we have done the first one, but it’s still a bit of work, so let’s save all of that for our next installment.

Collaboration Store, Part LIX

“When something you make doesn’t work, it didn’t work, not you. You, you work. You keep trying.”
Zach Klein

Last time, we created a couple of new shared functions to send over a logo image and associate that image with its base record. Unfortunately, the function that sends over the image file doesn’t actually work. Yes, it creates an attachment record on the target system, and yes, that attachment gets linked to its base record, but the image itself does not come across correctly, and the resulting file is not a valid image file. Yes, I should have tested that before I stuck the code out there, but it all seemed as if it should work, so I just threw it out there without first giving it a try.

I tried a few things to get it to go, but none of them did the trick. I went back to the getContent method instead of getContentBase64, but that didn’t work, so I tried getContentStream, but that didn’t do it, either. Then I tried adding a Content-Transfer-Encoding: base64 header, but that didn’t help, no matter what method I used to snag the content. So, it’s back to the drawing board on that one to see if we can’t figure out how to get that working correctly.

In the meantime, I decided to start logging all of this REST API activity so that I would have some record of what’s been happening between the instances. I have long thought that there should be some form of activity log tracking all of the important things going on with the records, and I even built a table for that early on, but that table was never used. This time, though, I was looking for something specific to the REST API activity, which has a number of specific data points. So, I created a new table called REST API Log to start tracking every request and response.

New REST API Log table

Then I added the following function to create records in this new table.

logRESTCall: function (targetGR, result, payload) {
	var logGR = new GlideRecord('x_11556_col_store_rest_api_log');
	logGR.instance = targetGR.getUniqueValue();
	logGR.url = result.url;
	logGR.method = result.method;
	if (payload) {
		logGR.request_body = JSON.stringify(payload, null, '\t');
	}
	logGR.response_code = result.status;
	if (result.obj) {
		logGR.response_body =  JSON.stringify(result.obj, null, '\t');
	} else {
		logGR.response_body =  result.body;
	}
	logGR.error = result.error;
	logGR.error_code = result.error_code;
	logGR.error_message = result.error_message;
	logGR.parse_error = result.parse_error;
	logGR.insert();
}

Then, at the end of each common REST API function, I added this line right before the final return statement:

this.logRESTCall(targetGR, result, payload);

Now, not every REST API call in the system uses these common functions, but my intent is to go back and correct that wherever appropriate, so eventually that should cover most of them, and then I can see what I need to do with the rest to get that activity logged as well. But it’s a start, anyway.

So now I have to get busy figuring out how to get my logo image over to another instance successfully. I’m sure that there is a way to do that; I just haven’t figured it out yet. Hopefully, we can explain how that is done next time out.