helpers/jrresult.js

/**
 * @module helpers/jrresult
 * @author jesse reichler <mouser@donationcoder.com>
 * @copyright 5/24/19

 * @description
 * Error helper class
*/

"use strict";


// modules
const assert = require("assert");

// helper modules
const jrhMisc = require("./jrh_misc");
const jrhExpress = require("./jrh_express");






//---------------------------------------------------------------------------
// JrResult is a class for returning an error from functions with enough information that it can be displayed to the user
// with helper methods for logging, etc.
/**
 * We use JrResult object instances to store the results of operations, where we may want to indicate a success or an error with additional information about the nature of the error.
 * The result can hold multiple errors, possibly keyed to fields (for example an error message corresponding to each input form variable)
 *
 * @class JrResult
 */

class JrResult {

	//---------------------------------------------------------------------------
	/**
	 * Creates an instance of JrResult.
	 * @memberof JrResult
	 */
	constructor() {
	}
	//---------------------------------------------------------------------------


	//---------------------------------------------------------------------------
	/**
	 * Helper function to create a new blank JrResult object
	 *
	 * @static
	 * @returns new JrResult object
	 */
	static makeNew() {
		// static helper.
		return new JrResult();
	}


	/**
	 * Helper function to create a new JrResult object and store an error for it
	 *
	 * @static
	 * @returns new JrResult object
	 */
	static makeError(msg) {
		const jrResult = new JrResult();
		jrResult.pushError(msg);
		return jrResult;
	}


	/**
	 * Helper function to create a new JrResult object and store a success message in it
	 *
	 * @static
	 * @returns new JrResult object
	 */
	static makeSuccess(msg) {
		if (msg === "" || msg === undefined) {
			// throw an error, or just do nothing, since lack of error means success
			throw new Error("makeSuccessInJrResultCannotHaveBlankReason");
		}
		const jrResult = new JrResult();
		jrResult.pushSuccess(msg);
		return jrResult;
	}

	/**
	 * Helper function to create a new JrResult object and store a generic message in it
	 *
	 * @static
	 * @returns new JrResult object
	 */
	static makeMessage(msg) {
		const jrResult = new JrResult();
		jrResult.pushMessage(msg);
		return jrResult;
	}


	/**
	 * make a clone of an existing jr result
	 *
	 * @static
	 * @param {object} source
	 * @returns new JrResult object
	 */
	static makeClone(source) {
		// first we make a new JrResult object, then copy properties
		const target = this.makeNew();
		target.copyFrom(source);
		return target;
	}
	//---------------------------------------------------------------------------



	//---------------------------------------------------------------------------
	/**
	 * clear all fields of the jrResult object
	 */

	clear() {
		this.typestr = undefined;
		this.errorFields = undefined;
		this.items = undefined;
		this.eData = undefined;
	}
	//---------------------------------------------------------------------------


	//---------------------------------------------------------------------------
	// accessors

	/**
	 * Set the type value which can be checked later
	 * @param {*} typestr
	 */
	setType(typestr) {
		this.typestr = typestr;
	}


	/**
	 * Get the type value for the object which can be set
	 */
	getType() { return this.typestr; }
	//---------------------------------------------------------------------------


	//---------------------------------------------------------------------------
	/**
	 * Checks if the jrResult is (contains) an error
	 * @returns true if there are any error items pushed into this jrResult
	 */
	isError() {
		if (this.items && this.items.error && this.items.error.length > 0) {
			// explicit error messages in the object
			return true;
		}
		if (this.errorFields && this.errorFields.length > 0) {
			// there are field specific errors registered
			return true;
		}
		return false;
	}
	//---------------------------------------------------------------------------



	//---------------------------------------------------------------------------
	// fields are key=> value pairs, used for input form errors typically

	/**
	 * Set a field-associated error; marks the result as an error and makes it possible for observer to check which specific field caused it
	 *
	 * @param {string} key
	 * @param {*} value
	 * @returns this
	 */
	setFieldError(key, value) {
		// ATTN: note that these alone aren't shown in error to string
		// for that you need to call pushFieldError
		if (this.errorFields === undefined) {
			this.errorFields = {};
		}
		this.errorFields[key] = value;
		return this;
	}


	/**
	 * Gets any error associated with this field key, or defaultVal if none set (defaults to undefined)
	 *
	 * @param {string} key
	 * @param {*} defaultval (if not provided, undefined will be used)
	 * @returns error message associated with key, or defaultVal if none set
	 */
	getFieldError(key, defaultval) {
		if (this.errorFields === undefined || this.errorFields[key] === undefined) {
			return defaultval;
		}
		return this.errorFields[key];
	}


	/**
	 * Generic helper to clear all items in the given section (errors, success, messages)
	 * @private
	 *
	 * @param {*} key
	 * @returns this
	 */
	clearSection(sectionKey) {
		if (this.items === undefined) {
			return this;
		}
		if (this.items[sectionKey] === undefined) {
			return this;
		}
		this.items[sectionKey].clear();

		return this;
	}


	// now we have more generic lists of messages/errors
	/**
	 * Generic helper to push a message into a given section (errors, success, messages)
	 * @private
	 *
	 * @param {string} sectionKey
	 * @param {*} msg - message to add
	 * @param {*} flagOnTop - if true, the new message will be pushed at top of list
	 * @returns this
	 */
	push(sectionKey, msg, flagOnTop) {
		if (this.items === undefined) {
			this.items = {};
		}
		if (this.items[sectionKey] === undefined) {
			this.items[sectionKey] = [msg];
		} else {
			if (flagOnTop) {
				this.items[sectionKey].unshift(msg);
			} else {
				this.items[sectionKey].push(msg);
			}
		}
		return this;
	}


	/**
	 * Set the error associated with a specific key
	 * ##### Notes:
	 *  * this also causes a general (non-feild) error to be added to the object, with the same message
	 *
	 * @param {string} key - the field name to set the error for
	 * @param {string} msg - the error messsage
	 * @returns this
	 */
	pushFieldError(key, msg) {
		// push a generic error, and also add a field error for it
		this.push("error", msg);
		this.setFieldError(key, msg);
		return this;
	}


	/**
	 * Set the error associated with a specific key, AND adds a different error message as a general error
	 *
	 * @param {string} key - the field name to set the error for
	 * @param {string} shortMsg - the error messsage to add to the field
	 * @param {string} longMsg - the error message to add as a generic error
	 * @returns this
	 */
	pushBiFieldError(key, shortMsg, longMsg) {
		// push an error, and also add a field error for it
		this.push("error", longMsg);
		this.setFieldError(key, shortMsg);
		return this;
	}


	/**
	 * Add a generic error to the result
	 *
	 * @param {string} msg
	 * @returns this
	 */
	pushError(msg) {
		this.push("error", msg);
		return this;
	}

	/**
	 * Add a generic exception error to the result
	 *
	 * @param {string} msg
	 * @returns this
	 */
	pushException(msg, e) {
		this.push("error", msg + ": " + e.toString());
		return this;
	}


	/**
	 * Add a generic error to the result, pushing it to the top of the error list
	 *
	 * @param {string} msg
	 * @returns this
	 */
	pushErrorOnTop(msg) {
		this.push("error", msg, true);
		return this;
	}


	/**
	 * Clear any previous error and then add a generic error to the result
	 *
	 * @param {string} msg
	 * @returns this
	 */
	setError(msg) {
		this.clearSection("error");
		this.push("error", msg);
		return this;
	}


	/**
	 * Add a generic message (not an error) to the result
	 *
	 * @param {string} msg
 	 * @param {boolean} flagOnTop - if true the message will be pushed on top; if unspecified it will not
	 * @returns this
	 */
	pushMessage(msg, flagOnTop) {
		this.push("message", msg, flagOnTop);
		return this;
	}


	/**
	 * Add a success message (not an error) to the result
	 *
	 * @param {string} msg
 	 * @param {boolean} flagOnTop - if true the message will be pushed on top; if unspecified it will not
	 * @returns this
	 */
	pushSuccess(msg, flagOnTop) {
		this.push("success", msg, flagOnTop);
		return this;
	}


	/**
	 * Set generic extra data for the result
	 *
	 * @param {string} key - key to store data in
	 * @param {*} val - data to store
	 * @returns this
	 */
	setExtraData(key, val) {
		if (!this.eData) {
			this.eData = [];
		}
		this.eData[key] = val;
		return this;
	}


	/**
	 * Get generic extra data for the result
	 *
	 * @param {string} key - key to retrieve data for
	 * @param {*} defaultVal - returned if key not set
	 * @returns extra data stored under key, or defaultVal if none
	 */
	getExtraData(key, defaultVal) {
		if (!this.eData || !(key in this.eData)) {
			return defaultVal;
		}
		return this.eData[key];
	}
	//---------------------------------------------------------------------------


	//---------------------------------------------------------------------------
	// merge source into us, adding errors

	/**
	 * This function is used to merge one result into another, combining errors and successes, etc.
	 * It's a rather elaborate function but serves an important purpose when we have multiple results and we care about errors in either.
	 *
	 * @param {object} source - source JrResult
	 * @param {boolean} flagMergeSourceToTop - whether to merge souce object messages above ours when combinging
	 * @returns this
	 */
	mergeIn(source, flagMergeSourceToTop) {
		// this is really an awkward function, i wonder if there isn't a better cleaner way to merge objects and arrays
		// this function is specific to the JrResult class, and not generic
		let key;

		if (!source) {
			return this;
		}

		/*
		if (!(source instanceof JrResult)) {
			throw (new Error("In JrResult mergeIn with improper source result of class " + (typeof source)));
		}
		*/

		// for fields, each keyed item should be a string; on the rare occasion we have an entry in both our field and source field with same key, we can append them.
		if (source.errorFields) {
			if (!this.errorFields) {
				this.errorFields = jrhMisc.shallowCopy(source.errorFields);
			} else {
				for (key in source.errorFields) {
					if (!this.errorFields[key]) {
						this.errorFields[key] = source.errorFields[key];
					} else {
						if (flagMergeSourceToTop) {
							this.errorFields[key] = source.errorFields[key] + " " + this.errorFields[key];
						} else {
							this.errorFields[key] = this.errorFields[key] + " " + source.errorFields[key];
						}
					}
				}
			}
		}

		if (source.items) {
			// but items need to be concatenated
			if (!this.items) {
				this.items = jrhMisc.shallowCopy(source.items);
			} else {
				for (key in source.items) {
					if (!this.items[key]) {
						this.items[key] = jrhMisc.shallowCopy(source.items[key]);
					} else {
						if (flagMergeSourceToTop) {
							this.items[key] = (source.items[key]).concat(this.items[key]);
						} else {
							this.items[key] = (this.items[key]).concat(source.items[key]);
						}
					}
				}
			}
		}

		// if our typestr is blank, use source typestr
		if (!this.typestr) {
			this.typestr = source.typestr;
		} else if (flagMergeSourceToTop && source.typestr) {
			this.typestr = source.typestr;
		}

		return this;
	}


	/**
	 * Just a shallow copy of the object
	 *
	 * @param {object} source JrResult
	 * @returns this
	 */
	copyFrom(source) {
		// first we make a new JrResult object, then copy properties
		Object.assign(this, source);
		return this;
	}
	//---------------------------------------------------------------------------



	//---------------------------------------------------------------------------
	/**
	 * Check if the passed value is a JrResult
	 *
	 * @static
	 * @param {*} obj
	 * @returns true if obj is an instance of JrResult
	 */
	static isJrResult(obj) {
		return obj instanceof JrResult;
	}


	/**
	 * Simple helper function thats lets us easily check for a case where no errors or messages were added to a JrResult (i.e. it can be completely ignored)
	 *
	 * @static
	 * @param {object} obj
	 * @returns true if the JrResult obj has no data
	 */
	static isBlank(obj) {
		// helper function
		if (!obj) {
			return true;
		}
		if (!obj.items && !obj.errorFields) {
			return true;
		}
		return false;
	}


	/**
	 * Simple helper function thats makes it easier to ignore a result if there is nothing important in it
	 *
	 * @static
	 * @returns undefined if thhis JrResult is blank
	 */
	undefinedIfBlank() {
		if (JrResult.isBlank(this)) {
			return undefined;
		}
		return this;
	}


	isEmpty() {
		return JrResult.isBlank(this);
	}
	//---------------------------------------------------------------------------


	//---------------------------------------------------------------------------
	// passport helpers

	/**
	 * Return the string associated with a passport error, OR convert the JrResult to an error string if they pass in a JrResult
	 *
	 * @static
	 * @param {object} info - the passport info error (or a jrResult)
	 * @returns error message string
	 */
	static passportOrJrResultErrorAsString(info) {
		if (JrResult.isJrResult(info)) {
			return info.getErrorsAsString();
		}
		if (info && JrResult.isJrResult(info.result)) {
			return info.result.getErrorsAsString();
		}
		if (typeof info === "string") {
			return info;
		}
		if (!info || !info.message) {
			return "unknown authorization error";
		}
		return info.message;
	}
	//---------------------------------------------------------------------------

















	//---------------------------------------------------------------------------
	// ATTN: its not fun how these functions all hardcode the session variable name .jrResult

	// session helpers for flash message save/load
	/**
	 * Add this JrResult to the (request)session, so it can be remembered for flash error messages (when we show an error on their next/redirected page request)
	 * ##### Notes
	 *  * We add the mergeIn function to combine multiple jrResults into one
	 *
	 * @param {*} req - express request
	 * @param {*} flagAddToTop - add it to the top of the list of such error messages
	 * @returns this
	 */
	addToSession(req, flagAddToTop) {
		// addd to session
		const jrResultSession = JrResult.getSessionedJrResult(req);

		if (!jrResultSession) {
			// just assign it since there is nothing in session -- but should we ASSIGN it instead?
			JrResult.setSessionedJrResult(req, this);
		} else {
			// merge it -- the problem is that the SESSION version is not a true jrresult object so we have to do it backwards
			this.mergeIn(jrResultSession, !flagAddToTop);
			JrResult.setSessionedJrResult(req, this);
		}
		return this;
	}


	/**
	 * Create a result from the session, or return undefined if there is none, then clear session jrResult
	 * ##### Notes
	 *  * Important: The results will be REMOVED from the session after this is called
	 *
	 * @param {*} req - express request
	 * @returns jrResult from session or undefined if none found
	 */
	static retrieveThenRemoveFromSession(req) {
		const jrResultSession = JrResult.getSessionedJrResult(req);
		if (!jrResultSession) {
			return undefined;
		}

		// create new result and load and ADD from session, then CLEAR session
		// NOTE: we need to *COPY* the session result because the session may not actually be a full JrResult object and may be a simple flat json {} object
		const jrResult = JrResult.makeClone(jrResultSession);
		// remove it from session
		JrResult.clearSessionedJrResult(req);

		return jrResult;
	}


	/**
	 * Get any existing result from session, merging it with any provided here, and clearing any existing result from session
	 *
	 * @param {*} flagSessionAtTop - if true, result errors will be placed at top
	 * @returns the new combined result in the session
	 */

	mergeInThenRemoveFromSession(req, flagAddToTop) {
		const jrResultSession = JrResult.getSessionedJrResult(req);
		if (jrResultSession) {
			this.mergeIn(jrResultSession, flagAddToTop);
			// remove it from session
			JrResult.clearSessionedJrResult(req);
		}
		return this;
	}
	//---------------------------------------------------------------------------


	//---------------------------------------------------------------------------
	// low level static functions that actually work with session values
	static setSessionedJrResult(req, jrResult) {
		// we copy it so caller can do what they want with their copy and it wont effect session data
		if (JrResult.isBlank(jrResult)) {
			// if there's nothing in it then don't even bother
			JrResult.clearSessionedJrResult(req);
			return;
		}
		req.session.arJrResult = JrResult.makeClone(jrResult);
	}

	static getSessionedJrResult(req) {
		return req.session.arJrResult;
	}

	static clearSessionedJrResult(req) {
		delete req.session.arJrResult;
	}
	//---------------------------------------------------------------------------



















	//---------------------------------------------------------------------------
	/**
	 * Experimental express middleware function that automatically grabs any pending jrResult error from session and makes it available in the locals variable of a view for display
	 * ##### Notes
	 * express middleware helper
	 * the idea here is we want to take any session jrResult found in session, and put it automatically in any render call res
	 * all this does is save us from having to make every render look like this:
	 *    	res.render("viewpage", {
	 *         jrResult: JrResult.restoreFromSession(req);
	 *      });
	 * see https://stackoverflow.com/questions/9285880/node-js-express-js-how-to-override-intercept-res-render-function
	 *
	 * ATTN: 5/27/19 -- although this worked flawlessly, we have decided to force the manaul use of this into all render calls, to have better control over it
	 * but the proper call now is a bit more involved, it should be like this:
	 *	res.render("urlpath", {
	 *      jrResult: jrContext.mergeSessionMessages(),
	 *      // or if we have no result of our own: jrResult: jrContext.mergeSessionMessages()
	 *      }
	 * this old code does NOT do a merge combine of session data with manual jrresult, so can no longer be used
	 *
	 *
	 * @static
	 * @param {*} options
	 */
	static _unusedCodeExpressMiddlewareInjectSessionResult(options) {
		options = options || {};
		// let safe = (options.unsafe === undefined) ? true : !options.unsafe;
		return (req, res, next) => {
			// grab reference of render
			const jRrender = res.render;
			// override logic
			res.render = (view, roptions, fn) => {
				// transfer any session jrResult into RESPONSE view available variable
				res.locals.jrResult = JrResult.retrieveThenRemoveFromSession(req);
				// continue with original render
				jRrender.call(this, view, roptions, fn);
			};
			next();
		};
	}
	//---------------------------------------------------------------------------


	//---------------------------------------------------------------------------
	/**
	 * Return a string containing all errors in the result
	 *
	 * @returns error string
	 */
	getErrorsAsString() {
		if (!this.items || !this.items.error || this.items.error.length <= 0) {
			return "";
		}
		const str = this.items.error.join(". ");
		return str;
	}


	/**
	 * Return a string containing all non-errors in the result
	 *
	 * @returns success string
	 */
	getSuccessAsString() {
		if (!this.items || !this.items.success || this.items.success.length <= 0) {
			return "";
		}
		const str = this.items.success.join(". ");
		return str;
	}
	//---------------------------------------------------------------------------







}





//---------------------------------------------------------------------------
// export the class
module.exports = JrResult;
//---------------------------------------------------------------------------