Periodic Review, Part V

“I find that the best way to do things is to constantly move forward and to never doubt anything and keep moving forward.”
John Frusciante

Last time, we wrapped up the initial testing of our first table and related form, so now it is time to move on to the other tables on our list. The next table that we will want to deal with is the table that contains the details for each run of the review process, which we will call Review Execution.

New Review Execution table

The first field that we will want to have is a reference back to the associated configuration record. We will call that one configuration. Then we will need a short and long description field, the run date, start and end times, some counters, a current state, and a completion code.

Fields for the new Review Execution table

Once we save all of the fields, we can pull up the form and arrange everything to suit our needs.

Form layout for the new Review Execution table

One last thing that we will want to do with this table is to set up a couple of state values, one for running and one for completed, and set the default value to running so that whenever a new record is created, it is automatically set to the running state.

Choices and default for the state field

That should take care of the Review Execution table. Now we will need to build tables for every notice that comes out of the review process and every item that appears on each notice. Let’s start with the notice table first, which we can call Review Notice.

Review Notice table

This table will have several reference fields, the first one being a link back to the associated execution. Other references will link to the email that was sent out, the recipient of the email, and the user who responded to the notice.

Fields for the new Review Notice table

With that completed, we can then lay out the form in the way that we would like it to appear.

Form layout for the Review Notice table

The last table that we will need to build before we can start looking at the actual process of sending out the notices is the table of items associated with each notice. We will call that one Review Notice Item.

Review Notice Item table

This one will contain reference fields as well, including a reference to the associated notice, but the reference to the actual item being reviewed will be a little bit different. Because we are setting this up as a generic process that can review virtually any item, for the link to the item we will be using a Document ID field for the reference. More on that a little later, but for now, here are the fields for this table.

Fields for the new Review Notice Item table

Both Reference type fields and Document ID type fields contain sys_ids, but on a reference field, the table containing the record with that sys_id is defined as a part of the field definition. On a Document ID field, the record could potentially be on any table in the system, so you need a second, dependent field of type Table to specify which table contains the record referenced. Fortunately for our purposes, the field does not need to be on the same table as the one that contains the Document ID. The table field that we need is actually a part of the Review Configuration where we define what items are to be reviewed. To set that up, we need to go into the field definition and select that field in the Dependent field section of the form.

Document ID dependent field configuration

With that out of the way, we can once again lay out the fields as we like on the associated form.

Form Layout for the new Review Notice Item table

That should be all we need to get started on building the actual process that performs the review. There will eventually be one or more tables yet to define, but we can save that until they are needed. For now, let’s set that aside so that next time we can start working on the actual review process itself.

Collaboration Store, Part LXXX

“Walk that walk and go forward all the time. Don’t just talk that talk, walk it and go forward. Also, the walk didn’t have to be long strides; baby steps counted too. Go forward.”
Chris Gardner

Last time, we added some code to our storefront to launch the application installation process. Today, we want to build a detail screen to show all of the detailed information for a specific app. Although we could create a new UI Page or Portal Page for that, and have the tile click branch to that page, it seems as if it would be better if we just had a simple modal pop-up screen so that we could remain on the main shopping experience page after closing the modal dialog. To begin, let’s just create a simple Service Portal widget that we can call up using spModal. We can call our widget Application Details and give it an ID of cs-application-details.

We have already gathered up the application data in the main widget, so we could simply pass everything that we have over to the pop-up widget; however, that would make the pop-up widget highly dependent on the main widget, which is something that I try to avoid. I think the better approach will be to pass in the sys_id of the app and let the pop-up widget fetch its own data from the database. For that, we can cut and paste most of the code that we already have in the main widget.

(function() {
	if (input) {
		data.sysId = input.sys_id;
		data.record = {};
		var appGR = new GlideRecord('x_11556_col_store_member_application');
		appGR.query();
		if (appGR.get(data.sysId)) {
			var item = {};
			data.record.name = appGR.getDisplayValue('name');
			data.record.description = appGR.getDisplayValue('description');
			data.record.logo = appGR.getValue('logo');
			data.record.version = appGR.getDisplayValue('current_version');
			data.record.provider = appGR.getDisplayValue('provider.name');
			data.record.providerLogo = appGR.provider.getRefRecord().getValue('logo');
			data.record.local = appGR.getDisplayValue('provider.instance') == gs.getProperty('instance_name');
			data.record.state = 0;
			if (appGR.getValue('application')) {
				data.record.state = 1;
				data.record.installedVersion = appGR.getDisplayValue('application.version');
				if (data.record.version == data.record.installedVersion) {
					data.record.state = 2;
				}
			}
			if (!data.record.local && data.record.state != 2) {
				data.record.attachmentId = getAttachmentId(data.record.sys_id, data.record.version);
			}
		}
	}

	function getAttachmentId(applicationId, version) {
		var attachmentId = '';

		var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
		versionGR.addQuery('member_application', applicationId);
		versionGR.addQuery('version', version);
		versionGR.query();
		if (versionGR.next()) {
			var attachmentGR = new GlideRecord('sys_attachment');
			attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
			attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
			attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
			attachmentGR.query();
			if (attachmentGR.next()) {
				attachmentId = attachmentGR.getUniqueValue();
			}
		}
		
		return attachmentId;
	}
})();

Before we get too excited about formatting all of this data for display, let’s just throw a single value onto the display and see if we can get the mechanics of bringing up the widget all working. Here is some simple HTML to get things started.

<div>
  <h3>{{c.data.record.name}}</h3>
</div>

That should be enough to get things going, so let’s pop back over to the main widget and see if we can set up a function in the Client script to call up this widget.

$scope.openApplicationModal = function(evt, item) {
	var modelOptions = {
		title: "${Application Details}",
		widget: "cs-application-details",
		widgetInput: {
			sys_id: item.sys_id
		},
		buttons: [],
		footerStyle: {
			display: 'none'
		},
		size: 'lg'
	};
	$scope.applicationModal = spModal.open(modelOptions);
};

Now that we have a function to call, we need to go into the HTML and set up the call to the function when the operator clicks on the tile.

<a href="javascript:void(0);" ng-click="openApplicationModal($event, item)" class="panel-body block height-100" sn-focus="{{::item.highlight}}" aria-labelledby="cs_app_{{::item.sys_id}}" aria-describedby="cs_app_desc_{{::item.sys_id}}">
  <div>
    <h3 class="h4 m-t-none m-b-xs text-overflow-ellipsis" title="{{::item.name}}" style="padding-bottom:1px" id="cs_app_{{::item.sys_id}}">{{::item.name}}</h3>
    <img ng-src="{{::item.logo}}.iix?t=small" ng-if="item.logo" alt="" class="m-r-sm m-b-sm item-image pull-left" aria-hidden="true"/>
    <div class="text-muted item-short-desc catalog-text-wrap" id="cs_app_desc_{{::item.sys_id}}">{{::item.description}}</div>
  </div>
</a>

That should be enough to be able to open up the store and give things the old college try.

Simple application details pop-up

Not bad. OK, now that we have the basic mechanics working, we need to design the layout of the pop-up and also add whatever functionality we might want such as links to any forms or actions. At this point in the process, we have not added that much in the way of extra data. There are no categories or keywords or comments or user ratings or statistics or much of anything else in the way of interesting information outside of the name and description of the application. Some or all of that may come in some future version, but for now, about the best we can do to add detail would be to add the version history and to throw in a few useful links.

Store application detail pop-up

The above example is for an app pulled down from the store. For local apps that have been pushed up to the store, the look would be similar, but with a few differences.

Local application detail pop-up

For the local application, we should probably continue with the modified background, just to be consistent, but you get the idea. To pull this off, we will have to fetch more data from the database, and work out all of the associated HTML. Once that is done, that should be good enough to push out a new version so that folks can try it all out at home. That’s still a bit of work, so let’s deal with all of that next time out.

Collaboration Store, Part LXXIX

“You just have to keep driving down the road. It’s going to bend and curve and you’ll speed up and slow down, but the road keeps going.”
Ellen DeGeneres

Last time, we got the ability to toggle between the card/tile view and table view working as it should, and we made an initial stab at making the local apps look a little different from the apps that could be or have been pulled down from the Host. Now we need to figure out what we want to happen when the operator clicks on any of the links that are present on the tiles or table rows. Currently, the main portion of the tile is clickable, and in the footer, the version number could be clickable as well and could potentially launch the install process. In fact, let’s take a look at that first.

Right now, if you want to install a version of a store application, you go to the version record and click on the Install button. Let’s take a quick peek at that UI Action and see how that works.

var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', current.sys_id);
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
	action.setRedirectURL('/upload.do?attachment_id=' + attachmentGR.getUniqueValue());
} else {
	gs.addErrorMessage('No Update Set XML file found attached to this version');
}

Basically, it just links to the stock upload.do page (which we hacked up a bit sometime back) with the attachment sys_id as a parameter. Assuming that we had the attachment sys_id included along with the rest of the row data, we could simply build an anchor tag to launch the install such as the following.

<a href="/upload.do?attachment_id={{::item.attachmentId}}">

To get the attachment sys_id, we could steal some of the UI Action code above to build a function for that purpose.

function getAttachmentId(applicationId, version) {
	var attachmentId = '';

	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application', applicationId);
	versionGR.addQuery('version', version);
	versionGR.query();
	if (versionGR.next()) {
		var attachmentGR = new GlideRecord('sys_attachment');
		attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
		attachmentGR.addQuery('table_sys_id', versionGR.getUniqueValue());
		attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
		attachmentGR.query();
		if (attachmentGR.next()) {
			attachmentId = attachmentGR.getUniqueValue();
		}
	}
		
	return attachmentId;
}

For local apps, or apps that are already installed and up to date, there is no action to take. But for apps that have not been installed, or those that have, but are not on the latest version, a link to the install process would be appropriate. Two mutually exclusive tags would cover both cases.

<span ng-if="item.local || item.state == 2" class="current-version" title="${Version} {{::item.version}}">v{{::item.version}}</span>
<a ng-if="!item.local && item.state != 2" class="{{['not-installed','earlier-version'][item.state]}}" href="/upload.do?attachment_id={{::item.attachmentId}}" title="${Click here to} {{item.state == 0?'install':'upgrade to'}} ${version} {{::item.version}}">v{{::item.version}}</a>

To test this out, we will need to publish an app on our Host instance and bring up the store on our Client instance to see how this looks.

Install/Upgrade link on nonlocal applications

That takes care of the install link. For the main action when clicking on the tile itself (or on the app name in the table view), we should have some way of displaying all of the details of the app. We could link to the existing form for the application table, but we might want something a little more formatted, and maybe even just a pop-up so that you don’t actually leave the shopping experience. Let’s see if we can throw something like that together next time out.

Collaboration Store, Part LXXIV

“Mistakes should be examined, learned from, and discarded; not dwelled upon and stored.”
Tim Fargo

Last time, we attempted to solve the problem of the instance logo image not being captured during the initial set-up process. Although the modifications that we made resolved the problem for a Host instance set-up, there is still a problem with the Client instances, as there is still no code in the set-up process that sends the logo image over the Host during registration. The periodic instance sync process won’t resolve that issue, either, as that compares what the Host has with what the Clients have, and the Host was never sent the image. We need to add some additional logic to send over the image when the Client is first registered with the Host. Here is the relevant code from the set-up widget:

if (data.instance_type == 'host') {
	csu.createUpdateWorker(mbrGR.getUniqueValue());
} else {
	var resp = csu.registerWithHost(mbrGR);
	if (resp.status == '202') {
		mbrGR.initialize();
		mbrGR.instance = input.store_info.instance;
		mbrGR.accepted = input.store_info.accepted;
		mbrGR.description = input.store_info.description;
		mbrGR.name = input.store_info.name;
		mbrGR.email = input.store_info.email;
		mbrGR.token = input.store_info.sys_id;
		mbrGR.active = true;
		mbrGR.host = true;
		mbrGR.insert();
		fixLogRecords(mbrGR);
	} else {
		mbrGR.deleteRecord();
		var errMsg = resp.error_message;
		if (resp.obj && resp.obj.result && resp.obj.result.error) {
			errMsg = resp.obj.result.error.message + ': ' + resp.obj.result.error.detail;
		}
		gs.addErrorMessage(errMsg);
		data.validationError = true;
	}
}

All we are doing here is sending over the basic information about the Client for the instance record on the Host, and then creating a record in our own instance table for the Host instance. There is no attempt to send over the associated logo image. We should be able to add a little something right after we fix the REST API log records that were created before the Host instance record was built.

if (logoId) {
	var attachmentGR = new GlideRecord('sys_attachment');
	if (attachmentGR.get(logoId)) {
		csu.pushImageAttachment(attachmentGR, mbrGR, 'x_11556_col_store_member_organization', resp.obj.result.info.sys_id);
	}
}

There are a couple of issues with this code, however. The first problem is that we are reusing the instance GlideRecord for both the Client instance as well as the Host instance, so if we want the logo sys_id from the Client instance, we need to grab that and save it before we initialize the record and start building the record for the Host instance.

var logoId = mbrGR.getValue('logo');
mbrGR.initialize();
mbrGR.instance = input.store_info.instance;
mbrGR.accepted = input.store_info.accepted;
mbrGR.description = input.store_info.description;
mbrGR.name = input.store_info.name;
mbrGR.email = input.store_info.email;
mbrGR.token = input.store_info.sys_id;
mbrGR.active = true;
mbrGR.host = true;
mbrGR.insert();
fixLogRecords(mbrGR);
if (logoId) {
	var attachmentGR = new GlideRecord('sys_attachment');
	if (attachmentGR.get(logoId)) {
		csu.pushImageAttachment(attachmentGR, mbrGR, 'x_11556_col_store_member_organization', resp.obj.result.info.sys_id);
	}
}

The other problem is that we need the sys_id of the Client instance record on the Host system, and the current registration process does not send back the sys_id of the instance record created during registration. We have to have that so that we can attach the logo image to that record, so we will need to go into the function that processes the registration request and add that data point to the response.

processRegistrationRequest: function(data) {
	var result = {body: {error: {}, status: 'failure'}};

	var mbrGR = new GlideRecord('x_11556_col_store_member_organization');
	if (mbrGR.get('instance', data.instance)) {
		result.status = 400;
		result.body.error.message = 'Duplicate registration error';
		result.body.error.detail = 'This instance has already been registered with this store.';
	} else {
		mbrGR.initialize();
		mbrGR.name = data.name;
		mbrGR.instance = data.instance;
		mbrGR.email = data.email;
		mbrGR.description = data.description;
		mbrGR.token = data.sys_id;
		mbrGR.active = true;
		mbrGR.host = false;
		mbrGR.accepted = new GlideDateTime();
		if (mbrGR.insert()) {
			result.status = 202;
			delete result.body.error;
			result.body.info = {};
			result.body.info.message = 'Registration complete';
			result.body.info.detail = 'This instance has been successfully registered with this store.';
			result.body.info.sys_id = mbrGR.getUniqueValue();
			result.body.status = 'success';
			sn_fd.FlowAPI.startSubflow('New_Collaboration_Store_Instance', {new_instance: data.instance});
		} else {
			result.status = 500;
			result.body.error.message = 'Internal server error';
			result.body.error.detail = 'There was a problem processing this registration request.';
		}
	}

	return result;
}

That should do it. Now, not only are we able to capture the logo image during the initial set-up process, if the instance is a Client instance, we also send that logo image over to the Host so that it can be distributed to all of the other Client instances. Of course, now we need a new Update Set that includes all of these changes, so here you go:

Same rules apply as before; this is a drop-in replacement for any of the previous 0.7.x version. More information on previewing, committing, and testing can be found here and here and here. And as always, feedback of any kind in the comments section is welcome, encouraged, and very much appreciated. Any and all information on your experiences, positive, negative, or otherwise, would be very welcome, and will give us a little something to review next time out.

Note to testers: On this version, it might be worthwhile to delete all of the instance records on your Host instance and have all of your Client instances re-register using a logo image to make sure all of this works. If all goes well, the logo images for all instances and all apps should appear on all instances. If you run into any issues, please report them in the comments, and if everything works out, please let us know that as well — thanks!

Scripted Value Columns, Part VI

“Do not be too timid and squeamish about your actions. All life is an experiment. The more experiments you make the better.”
Ralph Waldo Emerson

Last time, we used the new scripted value columns feature to create columns from the comments and work notes of an Incident. Today we are going to do something similar, but instead of working with journal entries, we will try something with the optional variables that can be associated with Service Catalog items. For our demonstration, we will use the Executive Desktop catalog item, which has a number of defined ordering options.

Executive Desktop catalog item

Let’s see if we can’t create a list of all orders for this item, and include some of the catalog item variables on the list. To begin, let’s make another copy of our example scripted value provider and call it ScriptedCatalogValueProvider.

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

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

	type: 'ScriptedCatalogValueProvider'
};

Let’s also make a copy of our last configuration script and change things over from the Incident table to the Requested Item table, and define some scripted value columns for some of the catalog item variables associated with this item.

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

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

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

	table: {
		all: [{
			name: 'sc_req_item',
			displayName: 'Requested Item',
			all: {
				filter: 'cat_item=e46305bdc0a8010a00645e608031eb0f',
				fields: 'number,request,requested_for',
				svcarray: [{
					name: 'cpu',
					label: 'CPU Speed',
					heading: 'CPU Speed',
					script: 'global.ScriptedCatalogValueProvider'
				},{
					name: 'memory',
					label: 'Memory',
					heading: 'Memory',
					script: 'global.ScriptedCatalogValueProvider'
				},{
					name: 'drive',
					label: 'Hard Drive',
					heading: 'Hard Drive',
					script: 'global.ScriptedCatalogValueProvider'
				},{
					name: 'os',
					label: 'Operating System',
					heading: 'Operating System',
					script: 'global.ScriptedCatalogValueProvider'
				}],
				aggarray: [],
				btnarray: [],
				refmap: {},
				actarray: []
			}
		}]
	},

	type: 'ScriptedValueConfig2'
});

Finally, let’s make a copy of our last test page and call it scripted_value_test_2, and then edit the widget options to use our new configuration file. Now let’s jump out to the Service Portal and pull up the page and see what we have so far.

First test of our new portal page

So far, so good. Everything seems to work, but of course all we have in our new columns is the random numbers that we threw in there for demonstration purposes earlier. But that just means that all we have left to do now is to figure out what kind of script we need to pull out the actual variable values that are associated with each catalog item order. To begin, let’s map our variable names, which are also our column names, to their catalog item variable (question) sys_ids.

questionMap: {
	cpu: 'e46305fbc0a8010a01f7d51642fd6737',
	memory: 'e463064ac0a8010a01f7d516207cd5ab',
	drive: 'e4630669c0a8010a01f7d51690673603',
	os: 'e4630688c0a8010a01f7d516f68c1504'
}

This will allow us to use some common code for all of the columns by passing in the correct sys_id for the catalog item variable associated with that column.

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

	var column = config.name;
	if (this.questionMap[column]) {
		response = this.getVariableValue(this.questionMap[column], item.sys_id);
	}

	return response;
}

Now we need to build the getVariableValue function, which takes the sys_id of the question and the sys_id of the requested item as arguments. To locate the value selected for this question for this order, we use the sc_item_option_mtom table, which maps requested items to individual question responses.

getVariableValue: function(questionId, itemId) {
	var response = '';

	var mtomGR = new GlideRecord('sc_item_option_mtom');
	mtomGR.addQuery('request_item', itemId);
	mtomGR.addQuery('sc_item_option.item_option_new', questionId);
	mtomGR.query();
	if (mtomGR.next()) {
		response = mtomGR.getDisplayValue('sc_item_option.value');
	}

	return response;
}

Now let’s save that and give the page another look.

Second test of our new portal page

Well, that’s better, but it is still not exactly what we want. The values that appear in the columns are the raw values for the variables, but what we would really like to see is the display value. To get that, we have to go all the way back to the original question choices and find the choice record that matches both the question and the answer. For that, you need the sys_id of the question and the value of the answer.

getDisplayValue: function(questionId, value) {
	var response = '';

	var choiceGR = new GlideRecord('question_choice');
	choiceGR.addQuery('question', questionId);
	choiceGR.addQuery('value', value);
	choiceGR.query();
	if (choiceGR.next()) {
		response = choiceGR.getDisplayValue('text');
	}

	return response;
}

To utilize this new function, we have to tweak our getVariableValue function just a little bit to make the call.

getVariableValue: function(questionId, itemId) {
	var response = '';

	var mtomGR = new GlideRecord('sc_item_option_mtom');
	mtomGR.addQuery('request_item', itemId);
	mtomGR.addQuery('sc_item_option.item_option_new', questionId);
	mtomGR.query();
	if (mtomGR.next()) {
		var value = mtomGR.getDisplayValue('sc_item_option.value');
		if (value) {
			response = this.getDisplayValue(questionId, value);
		}
	}

	return response;
}

Now let’s take one more look at that page now that we have made these modifications.

Third and final test of our new portal page

That’s better! Now we have the same text in our columns that the user saw when placing the order, and that’s what we really want to see here. Or at least, that’s what I wanted to see. You may be interested in something completely different, but that’s the whole point of this approach. You can basically script whatever you want to put whatever you want to put in whatever column or columns you would like to define. These are just a couple of examples, but there really is no limit to what you might be able to do with a little imagination and some Javascript.

So here is the final version of our second close-to-real-world example value provider script:

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

	questionMap: {
		cpu: 'e46305fbc0a8010a01f7d51642fd6737',
		memory: 'e463064ac0a8010a01f7d516207cd5ab',
		drive: 'e4630669c0a8010a01f7d51690673603',
		os: 'e4630688c0a8010a01f7d516f68c1504'
	},

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

		var column = config.name;
		if (this.questionMap[column]) {
			response = this.getVariableValue(this.questionMap[column], item.sys_id);
		}

		return response;
	},

	getVariableValue: function(questionId, itemId) {
		var response = '';

		var mtomGR = new GlideRecord('sc_item_option_mtom');
		mtomGR.addQuery('request_item', itemId);
		mtomGR.addQuery('sc_item_option.item_option_new', questionId);
		mtomGR.query();
		if (mtomGR.next()) {
			var value = mtomGR.getDisplayValue('sc_item_option.value');
			if (value) {
				response = this.getDisplayValue(questionId, value);
			}
		}

		return response;
	},

	getDisplayValue: function(questionId, value) {
		var response = '';

		var choiceGR = new GlideRecord('question_choice');
		choiceGR.addQuery('question', questionId);
		choiceGR.addQuery('value', value);
		choiceGR.query();
		if (choiceGR.next()) {
			response = choiceGR.getDisplayValue('text');
		}

		return response;
	},

	type: 'ScriptedCatalogValueProvider'
};

The added columns still do not align with the original columns, which I would like to fix before I build a new Update Set, and we still have a couple more wrapper widgets to address, but I think we are getting close. Maybe we can wrap this whole thing up in our next installment.

Collaboration Store, Part LXIX

“We all need people who will give us feedback. That’s how we improve.”
Bill Gates

Last time, we released a new batch of Update Sets for the latest iteration of this effort and put out a plea for folks to take it all out for a spin. We got quite a lot of good, detailed feedback this time (Thanks, Joe!), so let’s make a quick list of everything that has been reported so far.

  • Preview errors during install
  • Application publishing failed during logo image copy
  • Application publishing failed after logo image removal
  • Application publishing failed due to Host instance being off line
  • Application publishing succeeded with new logo image, but on Host instance, logo image was attached to the version record instead of the Update Set XML file

None of these are good, but let’s take a look at them one at a time.

Preview errors during install

This one, I am able to duplicate. I also received 20 Preview errors when installing the Update Set on a new instance. Every one of the errors is basically the same.

Preview errors from initial install

Every one of the 20 errors contains the same message text.

Could not find a record in sys_hub_flow_base for column model referenced in this update

Searching for that message, I came across this:

https://community.servicenow.com/community?id=community_question&sys_id=82095744db9c70d0fb1e0b55ca9619b2

The accepted answer seems to be that this error message comes out because the Flow that you are trying to install is not present on the target instance. Well, that’s understandable, since you haven’t committed the Update Set just yet, but it doesn’t seem to me that that should be considered an error. Everyone’s answer is just to accept the remote update, but if you are shooting for a clean install, it doesn’t really look good to have these errors pop up for no reason. I looked for a way to suppress them or eliminate them, but so far I have not found anything of value. So it looks like you just accept them and continue, which is what I suggested when I first put this out there to install, but I don’t really like it. Maybe one day I will find a way to keep these messages from coming out, but for now, this is just the way that it is.

Application publishing failed during logo image copy

This one I have not been able to duplicate, which is unfortunate, because I would like to resolve it, and resolve it in a way that I can prove by running tests before and after the fix. In all of my testing, I have never had an image copy fail, so I am not sure how to proceed. However, it does occur to me that a failed logo image copy should not kill the entire process. Yes, it would be good to have the image along with the rest of the artifacts, but if that is the only issue, it seems to me that the rest of the publishing process should proceed. Here is the copy image function as it stands in version 0.7:

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: ' +  JSON.stringify(values));
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' +  JSON.stringify(values));
	}

	return logoId;
}

The processError function that is called when things go South logs the details of the error, displays a message, and then adds an error property to the answer object. I think if I remove the error property from the answer object, then the publication process will not stop at this point and everything will continue as if there was no image associated with the application. This seems like the preferable approach, at least to me. Maybe something like this:

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: ' +  JSON.stringify(values));
			delete answer.error;
		}
	} else {
		answer = this.processError(answer, 'Unrecognizable response from logo attachment copy: ' +  JSON.stringify(values));
		delete answer.error;
	}

	return logoId;
}

That still doesn’t explain why this particular image could not be copied, but at least it would allow the publishing of the application to continue.

Application publishing failed after logo image removal

This is another one that I cannot seem to duplicate. The code related to an application image is fairly straightforward: if the app has an image and the store record does not, then it copies it over; otherwise, it does not do anything at all. If the app had no image, then if the publishing failed, it must have failed somewhere else, as the image copy function should not have even been invoked. Here is the relevant section of code:

if (sysAppGR.getValue('logo') && !mbrAppGR.getValue('logo')) {
	mbrAppGR.setValue('logo', this.copyLogoImage(answer));
}

If the app had no logo image, then nothing should have happened. I will have to look into this one a little deeper any maybe ask for a little more information before I understand what happened on this one.

Application publishing failed due to Host instance being off line

This is not actually a problem with the app, as there is no way to publish an application to a Host that is not up and running. but it does bring up an interesting question: should we check to see if the Host is available before we launch the process? That would at least prevent someone from going through half of the process only to have it die when it tries to move the artifacts over to the Host. We already have a getStoreInfo function that would tell us if the Host was available or not, so it wouldn’t take much to add a quick check before we launched the publishing process, and then inform the operator if things were not going to work out.

Application publishing succeeded with new logo image, but on Host instance, logo image was attached to the version record instead of the Update Set XML file

I have not found the source of this one just yet, but it appears to me that one or more sys_id values got passed to the wrong function or written to the wrong variable. Since everything turned out OK on the original Client, but ended up in the wrong place on the Host, the problem has to be in the REST API calls made from the Client to the Host. There are three calls that move attachments, one for the instance logo image, one for the application logo image, and one for the Update Set XML file attached to the version record. Either the logo image API call attached the logo to the wrong base record or the Update Set XML file call sent over the wrong attachment. A review of the relevant REST API call log records might reveal which one caused the problem, but I will dig through the code for both and see if I can understand how this might have happened. Obviously, you cannot install the app if you don’t have the Update Set XML file attached to the version record. This one definitely has to be fixed.

This was all great feedback, and very detailed, including copies of log file entries. That is very helpful in diagnosing these issue. If anyone else is having similar issues, please report them as well, and include as much information as you feel would be appropriate. And if someone has pulled this down and was able to run things without running into these issues, I would love to hear about that as well. As always, all feedback is welcome, positive, negative, or otherwise.

And Joe, if you are still willing to do a little more testing, try to publish a different app from your other Client, and see if you run into any similar issues with that. If you can find a fourth instance to join your trio, you might have the owner of that instance give this a shot as well. And thanks again for your assistance. It is very much appreciated. Thanks to all of you for helping to make this work the way that it should. I look forward to hearing more from anyone willing to give this all a try. Next time, we will take a look at any additional feedback, as well as any modifications that have been implemented as a result of the feedback that we have received thus far.

Collaboration Store, Part LXV

“There is no such thing as completion. These are only stages in an endless progression. There are no final outcomes or decisions, since nothing ever stays the same.”
Frederick Lenz

Last time, we finished adding the logging process to the remaining REST API calls in the CollaborationStoreUtils Script Include. Now we need to do the same thing for all of the remaining REST API calls in the InstanceSyncUtils Script Include. Here is the first one as it stands right now.

syncInstances: function(targetGR, instanceList) {
	var request  = new sn_ws.RESTMessageV2();
	request.setHttpMethod('get');
	request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
	request.setRequestHeader("Accept", "application/json");
	request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id');
	var response = request.execute();
	if (response.haveError()) {
		gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
	} else if (response.getStatusCode() == '200') {
		var jsonString = response.getBody();
		var jsonObject = {};
		try {
			jsonObject = JSON.parse(jsonString);
		} catch (e) {
			gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + jsonString);
		}
		if (Array.isArray(jsonObject.result)) {
			for (var i=0; i<instanceList.length; i++) {
				var thisInstance = instanceList[i];
				var remoteSysId = '';
				for (var j=0; j<jsonObject.result.length && remoteSysId == ''; j++) {
					if (jsonObject.result[j].instance == thisInstance) {
						remoteSysId = jsonObject.result[j].sys_id;
					}
				}
				if (remoteSysId == '') {
					remoteSysId = this.sendInstance(targetGR, thisInstance);
				}
				this.syncApplications(targetGR, thisInstance, remoteSysId);
			}
		} else {
			gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + response.getBody());
		}
	} else {
		gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + response.getStatusCode());
	}
}

Up to this point, we have always called the logging routine just before we returned the result object. In the above function, however, we call other functions that also make their own REST API calls, so it would be preferable to log this call before calling any other function that might make a call of its own. Because of this, not only will we need to restructure the code to build the result object that the logging function is expecting, we will also need to make the call to the logging function prior to making the call to the other functions in the instance sync process. To begin, we will build the result object in the normal manner by populating the url and method properties, and then using those values to populate the sn_ws.RESTMessageV2 object.

var result = {};
result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id';
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('Accept', 'application/json');

Once the sn_ws.RESTMessageV2 is fully populated, we can then obtain the response object by executing the call and then continue populating the result object with the values returned in the response.

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();
		gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + result.body);
	}
}
result.error = response.haveError();
if (result.error) {
	result.error_code = response.getErrorCode();
	result.error_message = response.getErrorMessage();
	gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
} else if (result.status != '200') {
	gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + result.status);
}

Now that the result object is fully populated, we can go ahead and make the call to the logging function before calling the other functions involved in the instance sync process.

this.logRESTCall(targetGR, result);
if (!result.error && result.status == '200' && result.obj) {
	if (Array.isArray(result.obj.result)) {
		for (var i=0; i<instanceList.length; i++) {
			var thisInstance = instanceList[i];
			var remoteSysId = '';
			for (var j=0; j<result.obj.result.length && remoteSysId == ''; j++) {
				if (result.obj.result[j].instance == thisInstance) {
					remoteSysId = result.obj.result[j].sys_id;
				}
			}
			if (remoteSysId == '') {
				remoteSysId = this.sendInstance(targetGR, thisInstance);
			}
			this.syncApplications(targetGR, thisInstance, remoteSysId);
		}
	} else {
		gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + result.body);
	}
}

Putting it all together, the entire new function now looks like this.

syncInstances: function(targetGR, instanceList) {
	var result = {};
	result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_organization?sysparm_fields=instance%2Csys_id';
	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('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();
			gs.error('InstanceSyncUtils.syncInstance - Unparsable JSON string returned from attempt to fetch instance list: ' + result.body);
		}
	}
	result.error = response.haveError();
	if (result.error) {
		result.error_code = response.getErrorCode();
		result.error_message = response.getErrorMessage();
		gs.error('InstanceSyncUtils.syncInstance - Error returned from attempt to fetch instance list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
	} else if (result.status != '200') {
		gs.error('InstanceSyncUtils.syncInstance - Invalid HTTP response code returned from attempt to fetch instance list: ' + result.status);
	}
	this.logRESTCall(targetGR, result);
	if (!result.error && result.status == '200' && result.obj) {
		if (Array.isArray(result.obj.result)) {
			for (var i=0; i<instanceList.length; i++) {
				var thisInstance = instanceList[i];
				var remoteSysId = '';
				for (var j=0; j<result.obj.result.length && remoteSysId == ''; j++) {
					if (result.obj.result[j].instance == thisInstance) {
						remoteSysId = result.obj.result[j].sys_id;
					}
				}
				if (remoteSysId == '') {
					remoteSysId = this.sendInstance(targetGR, thisInstance);
				}
				this.syncApplications(targetGR, thisInstance, remoteSysId);
			}
		} else {
			gs.error('InstanceSyncUtils.syncInstance - Invalid response body returned from attempt to fetch instance list: ' + result.body);
		}
	}
}

That takes care of the syncInstances function. Now we need to do the same with the syncApplications function, which currently look like this.

syncApplications: function(targetGR, thisInstance, remoteSysId) {
	var applicationList = [];
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	applicationGR.addQuery('provider.instance', thisInstance);
	applicationGR.query();
	while (applicationGR.next()) {
		applicationList.push(applicationGR.getDisplayValue('name'));
	}
	if (applicationList.length > 0) {
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader("Accept", "application/json");
		request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=name%2Csys_id&sysparm_query=provider%3D' + remoteSysId);
		var response = request.execute();
		if (response.haveError()) {
			gs.error('InstanceSyncUtils.syncApplications - Error returned from attempt to fetch application list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() == '200') {
			var jsonString = response.getBody();
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				gs.error('InstanceSyncUtils.syncApplications - Unparsable JSON string returned from attempt to fetch application list: ' + jsonString);
			}
			if (Array.isArray(jsonObject.result)) {
				for (var i=0; i<applicationList.length; i++) {
					var thisApplication = applicationList[i];
					var remoteAppId = '';
					for (var j=0; j<jsonObject.result.length && remoteAppId == ''; j++) {
						if (jsonObject.result[j].name == thisApplication) {
							remoteAppId = jsonObject.result[j].sys_id;
						}
					}
					if (remoteAppId == '') {
						remoteAppId = this.sendApplication(targetGR, thisApplication, thisInstance, remoteSysId);
					}
					this.syncVersions(targetGR, thisApplication, thisInstance, remoteAppId);
				}
			} else {
				gs.error('InstanceSyncUtils.syncApplications - Invalid response body returned from attempt to fetch application list: ' + response.getBody());
			}
		} else {
			gs.error('InstanceSyncUtils.syncApplications - Invalid HTTP response code returned from attempt to fetch application list: ' + response.getStatusCode());
		}
	} else {
		gs.info('InstanceSyncUtils.syncApplications - No applications to sync for instance ' + thisInstance);
	}
}

Using the same restructuring approach, we can convert the function to this.

syncApplications: function(targetGR, thisInstance, remoteSysId) {
	var applicationList = [];
	var applicationGR = new GlideRecord('x_11556_col_store_member_application');
	applicationGR.addQuery('provider.instance', thisInstance);
	applicationGR.query();
	while (applicationGR.next()) {
		applicationList.push(applicationGR.getDisplayValue('name'));
	}
	if (applicationList.length > 0) {
		var result = {};
		result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application?sysparm_fields=name%2Csys_id&sysparm_query=provider%3D' + remoteSysId;
		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('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();
				gs.error('InstanceSyncUtils.syncApplications - Unparsable JSON string returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
		result.error = response.haveError();
		if (result.error) {
			result.error_code = response.getErrorCode();
			result.error_message = response.getErrorMessage();
			gs.error('InstanceSyncUtils.syncApplications - Error returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
		} else if (result.status != '200') {
			gs.error('InstanceSyncUtils.syncApplications - Invalid HTTP response code returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
		}
		this.logRESTCall(targetGR, result);
		if (!result.error && result.status == '200' && result.obj) {
			if (Array.isArray(result.obj.result)) {
				for (var i=0; i<applicationList.length; i++) {
					var thisApplication = applicationList[i];
					var remoteAppId = '';
					for (var j=0; j<result.obj.result.length && remoteAppId == ''; j++) {
						if (result.obj.result[j].name == thisApplication) {
							remoteAppId = result.obj.result[j].sys_id;
						}
					}
					if (remoteAppId == '') {
						remoteAppId = this.sendApplication(targetGR, thisApplication, thisInstance, remoteSysId);
					}
					this.syncVersions(targetGR, thisApplication, thisInstance, remoteAppId);
				}
			} else {
				gs.error('InstanceSyncUtils.syncApplications - Invalid response body returned from attempt to fetch application list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
	} else {
		gs.info('InstanceSyncUtils.syncApplications - No applications to sync for instance ' + thisInstance);
	}
}

We can repeat this same refactoring exercise for the two other similar functions, syncVersions and syncAttachments, which now look like this.

syncVersions: function(targetGR, thisApplication, thisInstance, remoteAppId) {
	var versionList = [];
	var versionIdList = [];
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application.name', thisApplication);
	versionGR.addQuery('member_application.provider.instance', thisInstance);
	versionGR.query();
	while (versionGR.next()) {
		versionList.push(versionGR.getDisplayValue('version'));
		versionIdList.push(versionGR.getUniqueValue());
	}
	if (versionList.length > 0) {
		result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version?sysparm_fields=version%2Csys_id&sysparm_query=member_application%3D' + remoteAppId;
		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('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();
				gs.error('InstanceSyncUtils.syncVersions - Unparsable JSON string returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
		result.error = response.haveError();
		if (result.error) {
			result.error_code = response.getErrorCode();
			result.error_message = response.getErrorMessage();
			gs.error('InstanceSyncUtils.syncVersions - Error returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
		} else if (result.status != '200') {
			gs.error('InstanceSyncUtils.syncVersions - Invalid HTTP response code returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
		}
		this.logRESTCall(targetGR, result);
		if (!result.error && result.status == '200' && result.obj) {
			if (Array.isArray(result.obj.result)) {
				for (var i=0; i<versionList.length; i++) {
					var thisVersion = versionList[i];
					var thisVersionId = versionIdList[i];
					var remoteVerId = '';
					for (var j=0; j<result.obj.result.length && remoteVerId == ''; j++) {
						if (result.obj.result[j].version == thisVersion) {
							remoteVerId = result.obj.result[j].sys_id;
						}
					}
					if (remoteVerId == '') {
						remoteVerId = this.sendVersion(targetGR, thisVersion, thisApplication, thisInstance, remoteAppId);
					}
					this.syncAttachments(targetGR, thisVersionId, thisVersion, thisApplication, thisInstance, remoteVerId);
				}
			} else {
				gs.error('InstanceSyncUtils.syncVersions - Invalid response body returned from attempt to fetch version list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
	} else {
		gs.info('InstanceSyncUtils.syncVersions - No versions to sync for application ' + thisApplication + ' on instance ' + thisInstance);
	}
}
syncAttachments: function(targetGR, thisVersionId, thisVersion, thisApplication, thisInstance, remoteVerId) {
	var attachmentList = [];
	var attachmentGR = new GlideRecord('sys_attachment');
	attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
	attachmentGR.addQuery('table_sys_id', thisVersionId);
	attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
	attachmentGR.query();
	while (attachmentGR.next()) {
		attachmentList.push(attachmentGR.getUniqueValue());
	}
	if (attachmentList.length > 0) {
		var result = {};
		result.url = 'https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/sys_attachment?sysparm_fields=sys_id&sysparm_query=table_name%3Dx_11556_col_store_member_application_version%5Etable_sys_id%3D' + remoteVerId + '%5Econtent_typeCONTAINSxml';
		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('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();
				gs.error('InstanceSyncUtils.syncAttachments - Unparsable JSON string returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
			}
		}
		result.error = response.haveError();
		if (result.error) {
			result.error_code = response.getErrorCode();
			result.error_message = response.getErrorMessage();
			gs.error('InstanceSyncUtils.syncAttachments - Error returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.error_code + ' - ' + result.error_message);
		} else if (result.status != '200') {
			gs.error('InstanceSyncUtils.syncAttachments - Invalid HTTP response code returned from attempt to fetch attachment list from instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
		}
		this.logRESTCall(targetGR, result);
		if (!result.error && result.status == '200' && result.obj) {
			if (Array.isArray(result.obj.result)) {
				if (result.obj.result.length == 0) {
					this.sendAttachment(targetGR, attachmentList[0], remoteVerId, thisVersion, thisApplication);
				}
			} else {
				gs.error('InstanceSyncUtils.syncAttachments - Invalid response body returned from attempt to fetch attachment list: ' + result.body);
			}
		}
	} else {
		gs.info('InstanceSyncUtils.syncAttachments - No attachments to sync for version ' + thisVersionId + ' of application ' + thisApplication + ' on instance ' + thisInstance);
	}
}

That should take care of all of the REST API calls in all of the Script Includes in the application. Now every call will be recorded in the new table and linked to the instance to which the call was made. With the completion of the work on the images and the logging, it is about time to create yet another Update Set and turn it over to the testers for some serious regression testing. Before we do that, though, it would probably be a good idea to try all of this out ourselves and make sure that it all works. Let’s jump right into that next time out.

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.